Compare commits

...

472 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
d02211d389 feat: Add Flutter APK build script with dependency management and output paths 2025-09-30 14:42:06 +05:30
5086b3be98 Merge pull request 'Vaibhav_Enhancement-#1129' (#73) from Vaibhav_Enhancement-#1129 into main
Reviewed-on: #73
2025-09-30 09:09:35 +00:00
539e94fc99 feat: Rename "Comment" to "Note" in relevant UI components and dialogs 2025-09-30 13:25:31 +05:30
dbd4a42b7a feat: Improve comment action buttons layout and spacing in ContactDetailScreen 2025-09-30 11:56:32 +05:30
38ae9e3571 feat: Update GlobalProjectModel and ProjectModel to handle nullable date fields and improve JSON parsing 2025-09-30 11:03:33 +05:30
7f924ee533 feat: Add restore and delete functionality for comments with confirmation dialogs 2025-09-29 17:51:13 +05:30
d6587931fa feat: Implement restore and delete functionality for notes with confirmation dialog 2025-09-29 17:03:47 +05:30
8ad9690d89 feat: Allow multi-line input for description field in AddContactBottomSheet 2025-09-29 16:04:10 +05:30
b286ab854a feat: Add designation field to contact model and update add contact functionality 2025-09-29 16:03:03 +05:30
8576448a32 feat: Enhance AddEmployee functionality with email and organization selection support 2025-09-27 16:56:56 +05:30
075167e285 feat: Update Dashboard Overview to display total employees instead of absent count 2025-09-27 11:30:18 +05:30
fd7c338c05 refactor: Remove loading skeletons from attendance and project progress charts for improved performance 2025-09-25 18:35:39 +05:30
b5d8d41e42 feat: Redesign Dashboard Overview Widgets with enhanced metrics display and loading states 2025-09-25 17:04:22 +05:30
781a8dabaf feat: Implement recent tenant selection logic and enhance tenant loading process 2025-09-25 15:06:57 +05:30
98612db7b5 feat: Enhance AttendanceLogViewButton with state management and improved log display 2025-09-25 14:42:22 +05:30
7c21324b42 refactor: Remove unused tenant loading logic and streamline tenant selection process 2025-09-25 11:35:27 +05:30
1900e944e5 feat: Implement tenant selection logic and load saved tenant from local storage 2025-09-25 11:27:03 +05:30
53fefbba50 refactor: Update border radius for UI consistency in DocumentDetailsPage 2025-09-24 17:48:12 +05:30
9012218a44 refactor: Adjust border radius in EmployeeDetailPage and update padding in EmployeesScreen 2025-09-24 17:42:42 +05:30
fb26ba0757 feat: Add maxLines property to LabeledInput and update document tile to conditionally show date header 2025-09-24 17:38:24 +05:30
aa5ae29284 feat: Update label for visibility toggle to 'Show Deleted Contacts' 2025-09-24 17:22:09 +05:30
83d9d0689a feat: Implement tabbed navigation and enhance UI for expense and directory views 2025-09-24 17:16:38 +05:30
85d3dedbef feat: Enhance organization selection and fetching logic with reactive state management 2025-09-24 15:37:06 +05:30
1d9c416f68 refactor: Standardize border radius values across dashboard components 2025-09-24 15:04:51 +05:30
7d211e24f8 feat: Implement pagination and service filtering in daily task fetching logic 2025-09-23 15:02:37 +05:30
fc081c779e feat: Add submit button text to Attendance Filter bottom sheet 2025-09-22 17:44:02 +05:30
4cb60138c0 refactor: Simplify color assignment logic in AttendanceDashboardChart and ProjectProgressChart 2025-09-22 17:15:57 +05:30
68cfdf54d6 feat: Add service selection functionality and integrate with task fetching logic 2025-09-22 16:49:36 +05:30
83a8abbb87 feat: Implement organization selection functionality and integrate with employee fetching logic 2025-09-22 15:54:32 +05:30
17c7b9f10d feat: Update Tenant model to use Industry and TenantStatus objects and improve industry display in TenantCard 2025-09-22 14:39:48 +05:30
efb5564fcb feat: Add project refresh logic upon tenant selection to ensure updated project data 2025-09-22 14:27:25 +05:30
637426aea4 feat: Enhance organization selector to include 'All Organizations' option and improve selection logic 2025-09-22 12:14:48 +05:30
8ed67dcdf1 feat: Increase default timeout duration for API requests to enhance reliability 2025-09-22 11:21:23 +05:30
6863769b8a feat: Add organization selection and related API integration in attendance module 2025-09-21 18:17:00 +05:30
8d3c900262 feat: Implement tenant selection feature with UI and service integration 2025-09-21 16:39:22 +05:30
9362945d60 Merge pull request 'Vaibhav_Enhancement-#1253' (#72) from Vaibhav_Enhancement-#1253 into main
Reviewed-on: #72
2025-09-21 04:33:42 +00:00
b5e9c7b6a3 feat: Update base URL to stage API for development environment 2025-09-20 16:42:40 +05:30
04cbdab277 feat: Enhance daily task planning by fetching infra details and associated tasks; update API service for new endpoints 2025-09-20 16:32:11 +05:30
ae7ce851ee feat: Update User Document Filter to use radio buttons for document status selection 2025-09-19 17:49:27 +05:30
8c99ba287f Merge pull request 'feat: Add InvoiceLogs widget to display expense logs in the detail screen' (#71) from Vaibhav_Task-#1177 into main
Reviewed-on: #71
2025-09-19 06:54:28 +00:00
bbe7f4a215 feat: Add InvoiceLogs widget to display expense logs in the detail screen 2025-09-19 06:54:28 +00:00
85d776b60b Merge pull request 'Vaibhav_Task-#1177' (#70) from Vaibhav_Task-#1177 into main
Reviewed-on: #70
2025-09-19 04:39:58 +00:00
4836dd994c feat: Implement employee editing functionality; add prefill logic and update API service for createOrUpdateEmployee 2025-09-18 17:42:27 +05:30
544eb4dc79 feat: Add todaysAssigned field to WorkItem model and implement JSON parsing 2025-09-18 17:15:23 +05:30
957bae526f feat: Increase icon size in directory view for better visibility 2025-09-18 16:36:05 +05:30
a1cd212e74 style: Improve code formatting; enhance readability by adjusting line breaks and widget dimensions 2025-09-18 16:32:49 +05:30
47666c7897 feat: Enhance contact detail screen; implement reactive contact updates and improve note handling 2025-09-18 16:18:01 +05:30
e6f028d129 feat: Improve notification handling; enhance logging and ensure DocumentController registration before updates 2025-09-18 15:16:53 +05:30
1fafe77211 feat: Enhance document filtering; implement multi-select support and add date range filters 2025-09-18 12:30:44 +05:30
25b20fedda feat: Enhance dashboard refresh logic; add handling for various notification types and improve method organization 2025-09-18 11:01:03 +05:30
7d5d2b5bf4 feat: Refactor document upload UI; reposition and revalidate Document ID and Name fields 2025-09-17 17:17:57 +05:30
ef1403bec9 Merge pull request 'feat: Re-enable Firebase Messaging and FCM token handling; improve local storage initialization and logging' (#69) from Vaibhav_Task-#961 into main
Reviewed-on: #69
2025-09-17 06:17:56 +00:00
3b497fecaf feat: Update API endpoint configuration; enhance attendance logs sorting and grouping logic 2025-09-17 11:32:32 +05:30
8fb725a5cf Refactor Attendance Logs and Regularization Requests Tabs
- Changed AttendanceLogsTab from StatelessWidget to StatefulWidget to manage state for showing pending actions.
- Added a status header in AttendanceLogsTab to indicate when only pending actions are displayed.
- Updated filtering logic in AttendanceLogsTab to use filteredLogs based on the pending actions toggle.
- Refactored AttendanceScreen to include a search bar for filtering attendance logs by name.
- Introduced a new filter icon in AttendanceScreen for accessing the filter options.
- Updated RegularizationRequestsTab to use filteredRegularizationLogs for displaying requests.
- Modified TodaysAttendanceTab to utilize filteredEmployees for showing today's attendance.
- Cleaned up code formatting and improved readability across various files.
2025-09-16 18:06:19 +05:30
2517f2360e feat: Re-enable Firebase Messaging and FCM token handling; improve local storage initialization and logging 2025-09-15 18:08:19 +05:30
d2712b8288 Merge pull request 'Feature_Document' (#68) from Feature_Document into main
Reviewed-on: #68
2025-09-15 11:41:07 +00:00
fd7f108a20 feat: Add camera functionality to expense attachment; update logger configuration for improved performance 2025-09-12 15:38:42 +05:30
61acbb019b feat: Refactor DynamicMenuController to remove caching and auto-refresh; update error handling in DashboardScreen and UserProfileBar 2025-09-12 12:22:12 +05:30
be908a5251 feat: Improve document verification and rejection loading states; update permission checks for button visibility 2025-09-11 19:07:56 +05:30
5c923bb48b feat: Enhance expense payload construction with attachment change detection 2025-09-11 18:03:14 +05:30
bd6f175ca7 feat: Conditionally display Create Bucket option based on user permissions 2025-09-11 17:45:44 +05:30
229531c5bf feat: Add comprehensive validation for expense submission fields in bottom sheet 2025-09-11 17:32:13 +05:30
20365697a7 feat: Update navigation to employee profile screen in employees list 2025-09-11 17:19:30 +05:30
0cccdc6b05 feat: Add joining date functionality and enhance document upload validation 2025-09-11 17:06:26 +05:30
6d70afc779 feat: Add document verification and rejection functionality with remote logging support 2025-09-11 15:45:32 +05:30
a02887845b feat: Improve dynamic menu fetching with enhanced error handling and cache fallback 2025-09-09 10:47:22 +05:30
99bd26942c feat: Implement document editing functionality with permissions and attachment handling 2025-09-08 18:00:07 +05:30
bf84ef4786 feat: Swap colors for completed and remaining tasks in tasks overview 2025-09-05 17:37:58 +05:30
4d11a2ccf0 feat: Simplify document upload by removing initial data handling and existing file name references 2025-09-05 16:22:05 +05:30
e12e5ab13b feat: Enhance document management with delete/activate functionality, search, and inactive toggle 2025-09-05 15:14:49 +05:30
2133dedfae feat: Refactor employee detail navigation and add employee profile screen with tabbed interface 2025-09-04 17:41:11 +05:30
334023bf1b feat: Add document management features including document listing, details, and filtering
- Implemented DocumentsResponse and related models for handling document data.
- Created UserDocumentsPage for displaying user-specific documents with filtering options.
- Developed DocumentDetailsPage to show detailed information about a selected document.
- Added functionality for uploading documents with DocumentUploadBottomSheet.
- Integrated document filtering through UserDocumentFilterBottomSheet.
- Enhanced dashboard to include navigation to the document management section.
- Updated user profile right bar to provide quick access to user documents.
2025-09-04 16:56:49 +05:30
40a4a77af5 Merge pull request 'Dashboard_Charts' (#67) from Dashboard_Charts into main
Reviewed-on: #67
2025-09-01 09:41:21 +00:00
c27b226b58 feat: Format numerical values with comma separators in attendance and project progress charts 2025-09-01 15:10:28 +05:30
a0f3475c5e feat: Refactor user profile right bar for improved loading state and UI enhancements 2025-09-01 11:24:39 +05:30
3aab006bea feat: Enhance dashboard with projects, tasks, and teams overview widgets 2025-08-29 17:35:45 +05:30
f5d4ab8415 feat: Add attendance log screen and related functionalities
- Implemented AttendenceLogScreen to display employee attendance logs.
- Created RegularizationRequestsTab to manage regularization requests.
- Added TodaysAttendanceTab for viewing today's attendance.
- Removed outdated dashboard chart implementation.
- Updated dashboard screen to integrate new attendance overview and project progress charts.
- Refactored employee detail and employee screens to use updated controllers.
- Organized expense-related imports and components for better structure.
- Adjusted daily progress report to use the correct controller.
2025-08-29 15:53:19 +05:30
d62f8aa9ef feat: Add chart skeleton loader to enhance loading experience in dashboard 2025-08-28 16:28:54 +05:30
80d5fc5f21 Merge pull request 'Feature_Dynamic_Menu' (#66) from Feature_Dynamic_Menu into main
Reviewed-on: #66
2025-08-28 09:18:43 +00:00
a154872649 feat: Implement daily task planning and progress reporting features
- Added TaskListModel for managing daily tasks with JSON parsing.
- Introduced WorkStatusResponseModel and WorkStatus for handling work status data.
- Created MenuResponse and MenuItem models for dynamic menu management.
- Updated routes to reflect correct naming conventions for task planning screens.
- Enhanced DashboardScreen to include dynamic menu functionality and improved task statistics display.
- Developed DailyProgressReportScreen for displaying daily progress reports with filtering options.
- Implemented DailyTaskPlanningScreen for planning daily tasks with detailed views and actions.
- Refactored left navigation bar to align with updated task planning routes.
2025-08-28 14:48:05 +05:30
91184b48bb Refactor employee model imports and restructure employee-related files
- Updated import paths for employee model files to reflect new directory structure.
- Deleted obsolete models: JobRecentApplicationModel, LeadReportModel, Product, ProductOrderModal, ProjectSummaryModel, RecentOrderModel, TaskListModel, TimeLineModel, User, VisitorByChannelsModel.
- Introduced new AttendanceLogModel, AttendanceLogViewModel, AttendanceModel, TaskModel, TaskListModel, EmployeeInfo, and EmployeeModel with comprehensive fields and JSON serialization methods.
- Enhanced data handling in attendance and task management features.
2025-08-26 11:53:53 +05:30
c69e0d5221 Merge pull request 'Firebase_Final_Code' (#65) from Firebase_Final_Code into main
Reviewed-on: #65
2025-08-26 05:42:13 +00:00
ac6b6e6173 Merge branch 'Firebase_Final_Code' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Firebase_Final_Code 2025-08-26 11:10:57 +05:30
35a0228cbe refactor: update package name and application ID to 'com.marco.aiot'; comment out Firebase-related code 2025-08-26 11:09:51 +05:30
5977fef261 chore: update dependencies, adjust minimum SDK version, and refactor date formatting 2025-08-26 11:09:51 +05:30
311002f3ba Refactor expense deletion confirmation dialog and add validation to add expense form
- Replaced the custom delete confirmation dialog with a reusable ConfirmDialog widget for better code organization and reusability.
- Improved the add expense bottom sheet by implementing form validation using a GlobalKey and TextFormField.
- Enhanced user experience by adding validation for required fields and specific formats (e.g., GST, transaction ID).
- Updated the expense list to reflect changes in the confirmation dialog and improved the handling of attachments.
- Cleaned up code by removing unnecessary comments and ensuring consistent formatting.
2025-08-26 11:09:50 +05:30
8783e7a503 refactor: update package name and application ID to 'com.marco.aiot'; comment out Firebase-related code 2025-08-26 11:06:02 +05:30
8688e84779 chore: update dependencies, adjust minimum SDK version, and refactor date formatting 2025-08-23 17:19:20 +05:30
911cddf97c Refactor expense deletion confirmation dialog and add validation to add expense form
- Replaced the custom delete confirmation dialog with a reusable ConfirmDialog widget for better code organization and reusability.
- Improved the add expense bottom sheet by implementing form validation using a GlobalKey and TextFormField.
- Enhanced user experience by adding validation for required fields and specific formats (e.g., GST, transaction ID).
- Updated the expense list to reflect changes in the confirmation dialog and improved the handling of attachments.
- Cleaned up code by removing unnecessary comments and ensuring consistent formatting.
2025-08-22 16:31:48 +05:30
1af3ae2aa6 Merge branch 'Firebase_Final_Code' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Firebase_Final_Code 2025-08-19 15:14:08 +05:30
3ba3129b18 added firebase code 2025-08-19 15:13:13 +05:30
98d2dd4c46 chore: update version number to 1.0.0+6 2025-08-19 11:33:39 +05:30
cc0bb7aafc Merge pull request 'feat: enhance expense detail model and screen with activity logs and new dependencies' (#62) from Vaibhav_Issues_18_08_2025 into main
Reviewed-on: #62
2025-08-18 12:04:07 +00:00
6187d63ccc feat: enhance expense detail model and screen with activity logs and new dependencies 2025-08-18 12:04:07 +00:00
83804cde3f Merge pull request 'refactor: update application ID and improve attendance upload functionality' (#61) from Vaibhav_Issues_18_08_2025 into main
Reviewed-on: #61
2025-08-18 10:02:56 +00:00
1e48c686b2 refactor: update application ID and improve attendance upload functionality
- Changed application ID from "com.marco.aiotstage" to "com.marco.aiot".
- Made markTime and date parameters required in uploadAttendanceImage method.
- Added logic to handle date selection and ensure attendance logs are uploaded with the correct date.
- Enhanced UI components for better user experience in attendance and directory views.
2025-08-18 15:32:17 +05:30
f24bff4fad added firebase code 2025-08-18 11:04:55 +05:30
fa767ea201 chnaged selected colour from red to blue accent 2025-08-16 17:50:48 +05:30
c9882879ff fixed the issue when user licks on comment same multiple taps duplicate comments creating 2025-08-16 17:01:58 +05:30
a6da579f07 corrected the checkbox selection issue 2025-08-16 16:52:10 +05:30
9feb9d1b4b displayed comma seperated amount on expenses 2025-08-09 19:08:01 +05:30
2013447904 removed the extra boz coming for the dashboard chart 2025-08-08 15:33:25 +05:30
2fb3c36ba4 added pull down refresh 2025-08-08 15:19:29 +05:30
0e177e5a1f chnaged endpoint 2025-08-07 17:15:00 +05:30
7175ade940 made chnages for all employee 2025-08-07 17:14:04 +05:30
10e4a6e514 added permission for report action and repost task 2025-08-07 12:55:08 +05:30
77dcd9af8e added permission for add task 2025-08-07 12:49:02 +05:30
858fe7435d added permission based buttons 2025-08-07 12:26:50 +05:30
93f9a6e738 chnages date display as per utils 2025-08-07 11:34:33 +05:30
092fe21252 Merge pull request 'Vaibhav_Feature-#768' (#60) from Vaibhav_Feature-#768 into main
Reviewed-on: #60
2025-08-07 05:18:31 +00:00
1d17a8e109 chnaged the app init 2025-08-06 18:18:17 +05:30
7ec8b1e7bc added edge to edge in app 2025-08-06 18:14:23 +05:30
d205cc2014 added condition to accept date 2025-08-06 17:45:37 +05:30
a5058cd0bc managed the dropdown menu display 2025-08-06 16:30:34 +05:30
754f919cdc added infinite loading 2025-08-06 16:18:50 +05:30
b0c9a2c45f added add expense permission 2025-08-06 16:09:35 +05:30
3195fdd4a0 chnaged the pai by in sheet 2025-08-06 15:18:51 +05:30
06fc8a4c61 added validation 2025-08-06 11:53:03 +05:30
63e5caae24 handelled the update expense 2025-08-06 11:46:12 +05:30
aa76ec60cb made changes for employee get method 2025-08-05 20:35:22 +05:30
0401b41b3c Dynamic edit and Add expense title 2025-08-05 17:47:12 +05:30
0acd619d78 added edit functioanllity in expense 2025-08-05 17:41:36 +05:30
f1220cc018 handelled the loading 2025-08-05 10:24:42 +05:30
84811635d0 resolved the not opening detail screen o tap of non content 2025-08-05 10:14:43 +05:30
7dbc9138c6 refactor: Enhance ExpenseDetailController and ExpenseDetailScreen to support optional comments during expense status updates and improve code readability 2025-08-05 10:09:34 +05:30
f245f9accf refactor: Update API endpoints for employee retrieval to include projectId as a query parameter for improved functionality 2025-08-04 17:04:33 +05:30
0150400092 refactor: Replace Get.snackbar with showAppSnackbar for consistent error handling and improve code readability across expense-related controllers and views. 2025-08-02 17:39:08 +05:30
bba44d4d39 refactor: Update action item color handling and adjust icon size for improved UI consistency 2025-08-02 17:33:34 +05:30
7dd47ce460 refactor: Simplify ReportActionBottomSheet by removing unused imports, optimizing widget structure, and enhancing form handling for improved readability and maintainability. 2025-08-02 17:29:29 +05:30
d799093537 refactor: Enhance ForgotPasswordScreen and LoginOptionScreen for improved readability and maintainability by extracting widget methods and optimizing variable declarations. 2025-08-02 16:54:57 +05:30
9d9afe37b8 refactor: Clean up AttendanceActionButton and AttendanceFilterBottomSheet code by removing unused comment bottom sheet function and filter button properties for improved readability and maintainability. 2025-08-02 16:44:39 +05:30
fe66f35be7 Refactor attendance management: Split attendance screen into tabs, add attendance logs and regularization requests tabs, and improve filter functionality. Update attendance filter sheet and enhance UI components for better user experience. 2025-08-02 16:19:12 +05:30
70443d8e24 feat: Refactor EmployeesScreen for improved readability and structure 2025-08-02 15:34:38 +05:30
0f0eb51c15 feat: Refactor TeamBottomSheet and TeamMembersBottomSheet to use BaseBottomSheet for improved UI consistency 2025-08-02 15:22:24 +05:30
2518b65cb7 feat: Implement delete expense functionality with confirmation dialog 2025-08-02 14:57:34 +05:30
5f66c4c647 feat: Update AttachmentsSection to use RxList for reactive attachment management 2025-08-02 10:16:01 +05:30
d0cbfa987d feat: Refactor TeamMembersBottomSheet and CreateBucketBottomSheet for improved structure and readability 2025-08-01 17:26:11 +05:30
0f14fda83a feat: Refactor DirectoryFilterBottomSheet to manage state and improve filter functionality 2025-08-01 17:11:34 +05:30
7ce07c9b47 feat: Enhance AddContactController with submission state management
- Added `isSubmitting` state to prevent multiple submissions in AddContactController.
- Updated the `submitContact` method to handle submission state and validation.
- Refactored `AddContactBottomSheet` to utilize `BaseBottomSheet` for better UI consistency.
- Improved dynamic list handling for email and phone inputs in AddContactBottomSheet.
- Cleaned up controller initialization and field management in AddContactBottomSheet.
- Enhanced error handling and user feedback for required fields.
2025-08-01 16:42:29 +05:30
f5eed0a0b9 Refactor Expense Filter Bottom Sheet for improved readability and maintainability
- Extracted widget builders for Project, Expense Status, Date Range, Paid By, and Created By filters into separate methods.
- Simplified date selection logic by creating a reusable _selectDate method.
- Centralized input decoration for text fields.
- Updated Expense Screen to use local state for search query and history view toggle.
- Enhanced filtering logic for expenses based on search query and date.
- Improved UI elements in Daily Progress Report and Daily Task Planning screens, including padding and border radius adjustments.
2025-08-01 16:21:24 +05:30
6d29d444fa feat: refactor AddEmployeeBottomSheet and AssignProjectBottomSheet for improved UI and functionality 2025-07-31 18:17:48 +05:30
797df80890 feat: enhance bottom sheet components with dynamic button visibility and improved styling 2025-07-31 18:09:17 +05:30
3427c5bd26 refactor: streamline API call handling in ExpenseDetailController and enhance error logging 2025-07-31 16:57:15 +05:30
31966f4bc5 feat: add permission handling in ExpenseDetailScreen and enhance ExpenseDetailController with permission parsing 2025-07-31 16:44:05 +05:30
29f759ca9d Refactor expense reimbursement and filter UI components
- Updated ReimbursementBottomSheet to use BaseBottomSheet for consistent styling and functionality.
- Improved input field decorations and added spacing helpers for better layout.
- Simplified the employee selection process and integrated it into the new design.
- Refactored ExpenseDetailScreen to utilize controller initialization method.
- Enhanced ExpenseFilterBottomSheet with a cleaner structure and improved field handling.
- Removed unnecessary wrapper for ExpenseFilterBottomSheet and integrated it directly into the expense screen.
2025-07-31 13:13:00 +05:30
adf5e1437e feat: make statusId dynamic in reimbursement handling and update related components 2025-07-31 11:09:25 +05:30
9d49f2a92d feat: implement reimbursement functionality in ExpenseDetailController and add ReimbursementBottomSheet for expense reimbursement entry 2025-07-30 19:19:18 +05:30
cef3bd8a1e feat: add noOfPersons input field in AddExpenseBottomSheet for enhanced expense entry 2025-07-30 17:37:47 +05:30
2b34635a75 refactor: reorganize imports and enhance AddExpenseBottomSheet for improved readability and functionality 2025-07-30 17:19:46 +05:30
154cfdb471 refactor: adjust font weights and sizes in ToggleButton and ExpenseList for improved UI consistency 2025-07-30 16:52:25 +05:30
d28332b55d feat: implement ExpenseDetailModel and update ExpenseDetailController and Screen for improved expense detail handling 2025-07-30 16:45:21 +05:30
98836f8157 Refactor app_logger to remove storage permission request and improve log file handling 2025-07-30 11:37:38 +05:30
ddbc1ec1e5 Refactor ContactDetailScreen and DirectoryView for improved readability and performance
- Moved the Delta to HTML conversion logic outside of the ContactDetailScreen class for better separation of concerns.
- Simplified the handling of email and phone display in DirectoryView to show only the first entry, reducing redundancy.
- Enhanced the layout and structure of the ContactDetailScreen for better maintainability.
- Introduced a new ProjectLabel widget to encapsulate project display logic in the ContactDetailScreen.
- Cleaned up unnecessary comments and improved code formatting for consistency.
2025-07-29 18:05:37 +05:30
5bc811f91f feat: update package identifiers and versioning for staging environment 2025-07-29 16:38:13 +05:30
f4b905cd42 feat(build): enhance Gradle configuration with keystore properties and release signing setup 2025-07-29 09:57:24 +05:30
e5b3616245 Refactor expense models and detail screen for improved error handling and data validation
- Enhanced `ExpenseResponse` and `ExpenseData` models to handle null values and provide default values.
- Introduced a new `Filter` class to encapsulate filtering logic for expenses.
- Updated `ExpenseDetailScreen` to utilize a controller for fetching expense details and managing loading states.
- Improved UI responsiveness with loading skeletons and error messages.
- Refactored filter bottom sheet to streamline filter selection and reset functionality.
- Added visual indicators for filter application in the main expense screen.
- Enhanced expense detail display with better formatting and status color handling.
2025-07-28 12:09:13 +05:30
9124b815ef feat(expense): refactor expense data handling and response parsing 2025-07-25 12:00:33 +05:30
a83954c5c4 Merge branch 'Vaibhav_Feature-#768' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Vaibhav_Feature-#768 2025-07-25 10:46:32 +05:30
586d18565f feat(expense): implement expense filtering functionality with UI integration 2025-07-25 10:45:22 +05:30
e0ed35a671 feat(expense): implement update expense status functionality and UI integration 2025-07-25 10:45:22 +05:30
debc12bc1b feat(expense): add refresh functionality to expense list 2025-07-25 10:45:22 +05:30
d90673523a feat(directory): add 'Create Bucket' option to actions menu 2025-07-25 10:45:22 +05:30
d7b62323d6 fix(expense): remove unnecessary hit test behavior from expense list item 2025-07-25 10:45:22 +05:30
ff01c05a73 feat(expense): improve expense submission validation and UI feedback 2025-07-25 10:45:22 +05:30
a7bb24ee29 feat(expense): enhance expense submission process and UI feedback 2025-07-25 10:45:22 +05:30
af83d66390 feat: Add expense models and update expense detail screen
- Created ExpenseModel, Project, ExpenseType, PaymentMode, PaidBy, CreatedBy, and Status classes for expense management.
- Implemented JSON serialization and deserialization for expense models.
- Added ExpenseStatusModel and ExpenseTypeModel for handling status and type of expenses.
- Introduced PaymentModeModel for managing payment modes.
- Refactored ExpenseDetailScreen to utilize the new ExpenseModel structure.
- Enhanced UI components for better display of expense details.
- Added search and filter functionality in ExpenseMainScreen.
- Updated dependencies in pubspec.yaml to include geocoding package.
2025-07-25 10:45:21 +05:30
b40d371d43 feat(expense): add expense management screens and functionality 2025-07-25 10:41:32 +05:30
982c81f849 feat: Add connectivity handling and offline screen, update MPIN to 4-digit support 2025-07-24 17:35:59 +05:30
9e4c0378c6 feat: Enhance attendance dashboard by filtering roles with data and improving chart series mapping 2025-07-24 15:56:17 +05:30
069fa29aa7 Merge remote-tracking branch 'origin/main' into Vaibhav_Feature-#768 2025-07-24 10:49:25 +05:30
25a1331878 Merge pull request 'Assign_Project' (#56) from Assign_Project into main
Reviewed-on: #56
2025-07-24 04:52:00 +00:00
0e1b6e2a8c feat(expense): implement expense filtering functionality with UI integration 2025-07-22 13:01:07 +05:30
d0f42da30f feat(expense): implement update expense status functionality and UI integration 2025-07-21 18:35:27 +05:30
93cdaab2c2 feat(expense): add refresh functionality to expense list 2025-07-21 16:39:11 +05:30
4a01371fad feat(directory): add 'Create Bucket' option to actions menu 2025-07-21 16:22:18 +05:30
f7352eb3c3 fix(expense): remove unnecessary hit test behavior from expense list item 2025-07-21 16:12:13 +05:30
ee469f694e feat(expense): improve expense submission validation and UI feedback 2025-07-21 15:24:18 +05:30
6c0e73d870 feat(expense): enhance expense submission process and UI feedback 2025-07-21 09:54:13 +05:30
30318cd294 feat: Add expense models and update expense detail screen
- Created ExpenseModel, Project, ExpenseType, PaymentMode, PaidBy, CreatedBy, and Status classes for expense management.
- Implemented JSON serialization and deserialization for expense models.
- Added ExpenseStatusModel and ExpenseTypeModel for handling status and type of expenses.
- Introduced PaymentModeModel for managing payment modes.
- Refactored ExpenseDetailScreen to utilize the new ExpenseModel structure.
- Enhanced UI components for better display of expense details.
- Added search and filter functionality in ExpenseMainScreen.
- Updated dependencies in pubspec.yaml to include geocoding package.
2025-07-19 20:15:54 +05:30
8c5035d679 feat(expense): add expense management screens and functionality 2025-07-18 17:51:59 +05:30
372 changed files with 55979 additions and 15261 deletions

View File

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

View File

@ -3,42 +3,84 @@ plugins {
id "kotlin-android" id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin" id "dev.flutter.flutter-gradle-plugin"
id("com.google.gms.google-services")
}
// Load keystore properties from key.properties file
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
} }
android { android {
namespace = "com.example.marco" // Define the namespace for your Android application
namespace = "com.marcoonfieldwork.aiot"
// 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
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
// Configure Java compatibility options
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8
// Enable core library desugaring for Java 8+ APIs
coreLibraryDesugaringEnabled true
} }
// Configure Kotlin options for JVM target
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8 jvmTarget = JavaVersion.VERSION_1_8
} }
// Default configuration for your application
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // Specify your unique Application ID. This identifies your app on Google Play.
applicationId = "com.example.marcostage" applicationId = "com.marcoonfieldwork.aiot"
// You can update the following values to match your application needs. // Set minimum and target SDK versions based on Flutter's configuration
// For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = 23
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
// Set version code and name based on Flutter's configuration (from pubspec.yaml)
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
} }
// Define signing configurations for different build types
signingConfigs {
release {
// Reference the key alias from key.properties
keyAlias keystoreProperties['keyAlias']
// Reference the key password from key.properties
keyPassword keystoreProperties['keyPassword']
// Reference the keystore file path from key.properties
storeFile file(keystoreProperties['storeFile'])
// Reference the keystore password from key.properties
storePassword keystoreProperties['storePassword']
}
}
// Define different build types (e.g., debug, release)
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. // Apply the 'release' signing configuration defined above to the release build
// Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.release
signingConfig = signingConfigs.debug // Enable code minification to reduce app size
minifyEnabled true
// Enable resource shrinking to remove unused resources
shrinkResources true
// Other release specific configurations can be added here, e.g., ProGuard rules
} }
} }
} }
// Configure Flutter specific settings, pointing to the root of your Flutter project
flutter { flutter {
source = "../.." source = "../.."
} }
// Add required dependencies for desugaring
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
}

View File

@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "626581282477",
"project_id": "mtest-a0635",
"storage_bucket": "mtest-a0635.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:626581282477:android:8d3cf5009ff92ef67ff024",
"android_client_info": {
"package_name": "com.marcoonfieldwork.aiot"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyCBkDQRpbSdR0bo6pO4Bm0ZIdXkdaE3z-A"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@ -6,5 +6,6 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
</manifest> </manifest>

View File

@ -1,16 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.READ_CONTACTS"/> <uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application <application
android:label="Marco_Stage" android:label="On Field Work"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
@ -40,6 +38,9 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="high_importance_channel"/>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

View File

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

View File

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip

View File

@ -18,8 +18,9 @@ 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.2.1" 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
} }
include ":app" include ":app"

View File

@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "mtest-a0635",
"private_key_id": "39a69f7d2a64234784e0d0ce6c113052296d6dc1",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCimbxktO7PeQ4h\n81Ye2ZBcZjltDhqqD0o9XyLmNdHszzM056bwpJkvgoyyTJAIvR2fcBF3YQFyuC+1\nddLHtchP48FjflZ+zZzLp7oaA/Zh28OZLbCsu+Nm8vO3WJVoIaJYgi+jEz21G128\ncOIbgkKIpLMz1wQhPPOwDTuSdQ+WajWJb04/aNrmTRH1hMreyhHiIFmalcavUgc1\nY5FvgrGs7EaKjYBevoFN3dwmEXjfyHjfBSxnt1yytl9tbtINqdrYLYAMm1l3+KqO\nCGxicQE5kjI1osI2wRjsk105RHnpxPg2GZnI4vTIOkEY5czhRSOs94g2d628H6fq\nVzf9UqwtAgMBAAECggEARluLf3AjHbdd/CbVDwhJRRIeqye9NfTjxOaTrVWAfp2x\npKTQQbSXbE1rIAOtF3rthH3zsNpSzBcS3cwb5rqr8JW2qpySRNAnlp//ER7Bz9pO\nKsvwdO3gGj3qY117WNGk8/NxNXkv7FvpFY8q54hXzdSmjjnt2YwMThOLwXXRxt2B\nFxN3FpBWqw12epqS162nW2nIRJ34Jloil4J5x61Sc79MCFyCxyhMlrBkY+Ni/xb1\nigBXBjczxNiJqqDie0mc16WB1HMEcBP9Yjtb46Hhfs3NDDWNqDkoM8QmEMSg8EHy\nyjcSlf0Wj8I9Kf+0PZo+2FB2DbuhfA8IVR9U/c00KQKBgQDd/OULx6QpmUev1Gl/\nrwwN67ZUMJ72cRuwvLFsMTIzZ+oItO0AR1uMkRZ1crOMc490XNUvSCGP6piZQAn1\nro8qNAh+0Q/UvKHM1khOj/4DxEGZRnNOhe6QLZM9QNygENuEYfdYDD9wcQI9Xs+B\nMIOBsuuqUVHlsbvYkeYNS8M8swKBgQC7g3i1dYRC/bkNMthVS4GTlFRuLscyIjTi\nhruhdaSE+fBZ5RO3XDzz6oDHYcdo/z5ySqI7EIsckNRbwFsMCOjSP3xJapadPYwU\nIhZBU7lgNlPnHJ/BIUwA5JZqRqGTNWrFINUHZFp2RK/x2bYdfoqY8bq08eWs9gmR\nc7U7i+6jnwKBgGaO3isxExD89fewBQWuk70it1vyEp785rQimT3JBM5nJeLb49sL\nHKq2pU+hrH4pLY+vC/cKNidNVS8IPRG6kf4HiB0+7Td15rLCFSnmsI6A72Wm/MK8\ncdk+lRXpj4SMBT8GG8Yb8ns6WrSLxwaCqV8UkHhhlZqvIIAP998Qr6StAoGAQTwr\n8nU/3k6G4qCdwo7SNZWVCgAcLMTZwTU+cZ2L7vdFNwELKu9cBT/ALZ1G0rB5+Skd\n546J1xZLyt/QzQ8McJjFlIUQgQO4iAiT1YZbJ62+4tiCe54p4uWjrrWD4MLkslAJ\nzNiM4DhlPa6QPRKZBTyTx/+f99xg18l5c43rJ+ECgYBkXMfjdn8SOaG6ggJrf1xx\nas49vwAscx4AJaOdVu3D8lCwoNCuAJhBHcFqsJ0wEHWpsqKAdXxqX/Nt2x8t7zL0\nPoRCvfsq5P7GdRrNhrHxLwjDqh+OS+Ow6t0esPQ5RPBgtjvthAlb7bV2nIfkpmdl\nFbjML8vkXk9iPJsbAfO2jw==\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-fbsvc@mtest-a0635.iam.gserviceaccount.com",
"client_id": "111097905744982732087",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40mtest-a0635.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

55
build_release.sh Normal file
View File

@ -0,0 +1,55 @@
#!/bin/bash
# ===============================
# Flutter APK Build Script (AAB Disabled)
# ===============================
# Exit immediately if a command exits with a non-zero status
set -e
# Colors for pretty output
GREEN='\033[0;32m'
CYAN='\033[0;36m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# App info
APP_NAME="On Field Work"
BUILD_DIR="build/app/outputs"
echo -e "${CYAN}🚀 Starting Flutter build script for $APP_NAME...${NC}"
# Step 1: Clean previous builds
echo -e "${YELLOW}🧹 Cleaning previous builds...${NC}"
flutter clean
# Step 2: Get dependencies
echo -e "${YELLOW}📦 Fetching dependencies...${NC}"
flutter pub get
# ==============================
# Step 3: Build AAB (Commented)
# ==============================
# echo -e "${CYAN}🏗 Building AAB file...${NC}"
# flutter build appbundle --release
# Step 4: Build APK
echo -e "${CYAN}🏗 Building APK file...${NC}"
flutter build apk --release
# Step 5: Show output paths
# AAB_PATH="$BUILD_DIR/bundle/release/app-release.aab"
APK_PATH="$BUILD_DIR/apk/release/app-release.apk"
echo -e "${GREEN}✅ Build completed successfully!${NC}"
# echo -e "${YELLOW}📍 AAB file: ${CYAN}$AAB_PATH${NC}"
echo -e "${YELLOW}📍 APK file: ${CYAN}$APK_PATH${NC}"
# Optional: open the folder (Mac/Linux)
if command -v xdg-open &> /dev/null
then
xdg-open "$BUILD_DIR"
elif command -v open &> /dev/null
then
open "$BUILD_DIR"
fi

3
devtools_options.yaml Normal file
View File

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@ -368,7 +368,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco; 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.example.marco.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.example.marco.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.example.marco.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.example.marco; 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.example.marco; 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

@ -0,0 +1,584 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:geolocator/geolocator.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/widgets/my_image_compressor.dart';
import 'package:on_field_work/helpers/widgets/time_stamp_image_helper.dart';
import 'package:on_field_work/model/attendance/attendance_log_model.dart';
import 'package:on_field_work/model/attendance/attendance_log_view_model.dart';
import 'package:on_field_work/model/attendance/attendance_model.dart';
import 'package:on_field_work/model/attendance/organization_per_project_list_model.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/model/project_model.dart';
import 'package:on_field_work/model/regularization_log_model.dart';
class AttendanceController extends GetxController {
// ------------------ Data Models ------------------
final List<AttendanceModel> attendances = <AttendanceModel>[];
final List<ProjectModel> projects = <ProjectModel>[];
final List<EmployeeModel> employees = <EmployeeModel>[];
final List<AttendanceLogModel> attendanceLogs = <AttendanceLogModel>[];
final List<RegularizationLogModel> regularizationLogs =
<RegularizationLogModel>[];
final List<AttendanceLogViewModel> attendenceLogsView =
<AttendanceLogViewModel>[];
// ------------------ Organizations ------------------
final List<Organization> organizations = <Organization>[];
Organization? selectedOrganization;
final RxBool isLoadingOrganizations = false.obs;
// ------------------ States ------------------
String selectedTab = 'todaysAttendance';
// Reactive date range
final Rx<DateTime> startDateAttendance =
DateTime.now().subtract(const Duration(days: 7)).obs;
final Rx<DateTime> endDateAttendance =
DateTime.now().subtract(const Duration(days: 1)).obs;
final RxBool isLoading = true.obs;
final RxBool isLoadingProjects = true.obs;
final RxBool isLoadingEmployees = true.obs;
final RxBool isLoadingAttendanceLogs = true.obs;
final RxBool isLoadingRegularizationLogs = true.obs;
final RxBool isLoadingLogView = true.obs;
final RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
final RxBool showPendingOnly = false.obs;
final RxString searchQuery = ''.obs;
@override
void onInit() {
super.onInit();
_initializeDefaults();
}
void _initializeDefaults() {
_setDefaultDateRange();
}
void _setDefaultDateRange() {
final DateTime today = DateTime.now();
startDateAttendance.value = today.subtract(const Duration(days: 7));
endDateAttendance.value = today.subtract(const Duration(days: 1));
logSafe(
'Default date range set: ${startDateAttendance.value} to ${endDateAttendance.value}',
);
}
// ------------------ Computed Filters ------------------
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 {
projectId ??= Get.find<ProjectController>().selectedProject?.id;
if (projectId == null) {
logSafe(
'No project selected for attendance refresh from notification',
level: LogLevel.warning,
);
return;
}
await fetchProjectData(projectId);
logSafe(
'Attendance data refreshed from notification for project $projectId',
);
}
Future<void> fetchTodaysAttendance(String? projectId) async {
if (projectId == null) return;
isLoadingEmployees.value = true;
final List<dynamic>? response = await ApiService.getTodaysAttendance(
projectId,
organizationId: selectedOrganization?.id,
);
if (response != null) {
employees
..clear()
..addAll(
response
.map<EmployeeModel>(
(dynamic e) => EmployeeModel.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
for (final EmployeeModel emp in employees) {
uploadingStates[emp.id] = false.obs;
}
logSafe(
'Employees fetched: ${employees.length} for project $projectId',
);
} else {
logSafe(
'Failed to fetch employees for project $projectId',
level: LogLevel.error,
);
}
isLoadingEmployees.value = false;
update();
}
Future<void> fetchOrganizations(String projectId) async {
isLoadingOrganizations.value = true;
// Keep original return type inference from your ApiService
final response = await ApiService.getAssignedOrganizations(projectId);
if (response != null) {
organizations
..clear()
..addAll(response.data);
logSafe('Organizations fetched: ${organizations.length}');
} else {
logSafe(
'Failed to fetch organizations for project $projectId',
level: LogLevel.error,
);
}
isLoadingOrganizations.value = false;
update();
}
// ------------------ Attendance Capture ------------------
Future<bool> captureAndUploadAttendance(
String id,
String employeeId,
String projectId, {
String comment = 'Marked via mobile app',
required int action,
bool imageCapture = true,
String? markTime,
String? date,
}) async {
try {
_setUploading(employeeId, true);
final XFile? image = await _captureAndPrepareImage(
employeeId: employeeId,
imageCapture: imageCapture,
);
if (imageCapture && image == null) {
return false;
}
final Position? position = await _getCurrentPositionSafely();
if (position == null) return false;
final String imageName = imageCapture
? ApiService.generateImageName(
employeeId,
employees.length + 1,
)
: '';
final DateTime effectiveDate =
_resolveEffectiveDateForAction(action, employeeId);
final DateTime now = DateTime.now();
final String formattedMarkTime =
markTime ?? DateFormat('hh:mm a').format(now);
final String formattedDate =
date ?? DateFormat('yyyy-MM-dd').format(effectiveDate);
final bool result = await ApiService.uploadAttendanceImage(
id,
employeeId,
image,
position.latitude,
position.longitude,
imageName: imageName,
projectId: projectId,
comment: comment,
action: action,
imageCapture: imageCapture,
markTime: formattedMarkTime,
date: formattedDate,
);
if (result) {
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;
} catch (e, stacktrace) {
logSafe(
'Error uploading attendance',
level: LogLevel.error,
error: e,
stackTrace: stacktrace,
);
return false;
} finally {
_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;
}
}
Future<bool> _handleLocationPermission() async {
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
logSafe(
'Location permissions are denied',
level: LogLevel.warning,
);
return false;
}
}
if (permission == LocationPermission.deniedForever) {
logSafe(
'Location permissions are permanently denied',
level: LogLevel.error,
);
return false;
}
return true;
}
// ------------------ Attendance Logs ------------------
Future<void> fetchAttendanceLogs(
String? projectId, {
DateTime? dateFrom,
DateTime? dateTo,
}) async {
if (projectId == null) return;
isLoadingAttendanceLogs.value = true;
final List<dynamic>? response = await ApiService.getAttendanceLogs(
projectId,
dateFrom: dateFrom,
dateTo: dateTo,
organizationId: selectedOrganization?.id,
);
if (response != null) {
attendanceLogs
..clear()
..addAll(
response
.map<AttendanceLogModel>(
(dynamic e) => AttendanceLogModel.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
logSafe('Attendance logs fetched: ${attendanceLogs.length}');
} else {
logSafe(
'Failed to fetch attendance logs for project $projectId',
level: LogLevel.error,
);
}
isLoadingAttendanceLogs.value = false;
update();
}
Map<String, List<AttendanceLogModel>> groupLogsByCheckInDate() {
final Map<String, List<AttendanceLogModel>> groupedLogs =
<String, List<AttendanceLogModel>>{};
for (final AttendanceLogModel logItem in attendanceLogs) {
final String checkInDate = logItem.checkIn != null
? DateFormat('dd MMM yyyy').format(logItem.checkIn!)
: 'Unknown';
groupedLogs.putIfAbsent(
checkInDate,
() => <AttendanceLogModel>[],
)..add(logItem);
}
final List<MapEntry<String, List<AttendanceLogModel>>> sortedEntries =
groupedLogs.entries.toList()
..sort(
(MapEntry<String, List<AttendanceLogModel>> a,
MapEntry<String, List<AttendanceLogModel>> b) {
if (a.key == 'Unknown') return 1;
if (b.key == 'Unknown') return -1;
final 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 ------------------
Future<void> fetchRegularizationLogs(String? projectId) async {
if (projectId == null) return;
isLoadingRegularizationLogs.value = true;
final List<dynamic>? response = await ApiService.getRegularizationLogs(
projectId,
organizationId: selectedOrganization?.id,
);
if (response != null) {
regularizationLogs
..clear()
..addAll(
response
.map<RegularizationLogModel>(
(dynamic e) => RegularizationLogModel.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
logSafe(
'Regularization logs fetched: ${regularizationLogs.length}',
);
} else {
logSafe(
'Failed to fetch regularization logs for project $projectId',
level: LogLevel.error,
);
}
isLoadingRegularizationLogs.value = false;
update();
}
// ------------------ Attendance Log View ------------------
Future<void> fetchLogsView(String? id) async {
if (id == null) return;
isLoadingLogView.value = true;
final List<dynamic>? response = await ApiService.getAttendanceLogView(id);
if (response != null) {
attendenceLogsView
..clear()
..addAll(
response
.map<AttendanceLogViewModel>(
(dynamic e) => AttendanceLogViewModel.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
attendenceLogsView.sort(
(AttendanceLogViewModel a, AttendanceLogViewModel b) =>
(b.activityTime ?? DateTime(2000))
.compareTo(a.activityTime ?? DateTime(2000)),
);
logSafe('Attendance log view fetched for ID: $id');
} else {
logSafe(
'Failed to fetch attendance log view for ID $id',
level: LogLevel.error,
);
}
isLoadingLogView.value = false;
update();
}
// ------------------ Combined Load ------------------
Future<void> loadAttendanceData(String projectId) async {
isLoading.value = true;
await fetchProjectData(projectId);
isLoading.value = false;
}
Future<void> fetchProjectData(String? projectId) async {
if (projectId == null) return;
await fetchOrganizations(projectId);
switch (selectedTab) {
case 'todaysAttendance':
await fetchTodaysAttendance(projectId);
break;
case 'attendanceLogs':
await fetchAttendanceLogs(
projectId,
dateFrom: startDateAttendance.value,
dateTo: endDateAttendance.value,
);
break;
case 'regularizationRequests':
await fetchRegularizationLogs(projectId);
break;
}
logSafe(
'Project data fetched for project ID: $projectId, tab: $selectedTab',
);
update();
}
// ------------------ UI Interaction ------------------
Future<void> selectDateRangeForAttendance(
BuildContext context,
AttendanceController controller,
) async {
final DateTime today = DateTime.now();
final DateTimeRange? picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2022),
lastDate: today.subtract(const Duration(days: 1)),
initialDateRange: DateTimeRange(
start: startDateAttendance.value,
end: endDateAttendance.value,
),
);
if (picked != null) {
startDateAttendance.value = picked.start;
endDateAttendance.value = picked.end;
logSafe(
'Date range selected: ${startDateAttendance.value} to ${endDateAttendance.value}',
);
await controller.fetchAttendanceLogs(
Get.find<ProjectController>().selectedProject?.id,
dateFrom: picked.start,
dateTo: picked.end,
);
}
}
}

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'; // <-- logging 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,58 +41,55 @@ 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();
logSafe("Attempting login for user: ${loginData['username']}", ); logSafe("Attempting login for user: ${loginData['username']}");
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();
logSafe("Login successful for user: ${loginData['username']}", ); enableRemoteLogging();
Get.toNamed('/home'); logSafe("Login successful for user: ${loginData['username']}");
Get.offNamed('/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;
} }
} }
Future<void> _handleRememberMe() async { Future<void> _handleRememberMe() async {
if (isChecked.value) { if (isChecked.value) {
await LocalStorage.setToken('username', basicValidator.getController('username')!.text); await LocalStorage.setToken(
await LocalStorage.setToken('password', basicValidator.getController('password')!.text); 'username', basicValidator.getController('username')!.text);
await LocalStorage.setToken(
'password', basicValidator.getController('password')!.text);
await LocalStorage.setBool('remember_me', true); await LocalStorage.setBool('remember_me', true);
} else { } else {
await LocalStorage.removeToken('username'); await LocalStorage.removeToken('username');
@ -114,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,11 +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: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();
@ -42,7 +44,8 @@ class MPINController extends GetxController {
/// Handle digit entry and focus movement /// Handle digit entry and focus movement
void onDigitChanged(String value, int index, {bool isRetype = false}) { void onDigitChanged(String value, int index, {bool isRetype = false}) {
logSafe("onDigitChanged -> index: $index, value: $value, isRetype: $isRetype"); logSafe(
"onDigitChanged -> index: $index, value: $value, isRetype: $isRetype");
final nodes = isRetype ? retypeFocusNodes : focusNodes; final nodes = isRetype ? retypeFocusNodes : focusNodes;
if (value.isNotEmpty && index < 3) { if (value.isNotEmpty && index < 3) {
nodes[index + 1].requestFocus(); nodes[index + 1].requestFocus();
@ -136,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
@ -212,7 +216,8 @@ class MPINController extends GetxController {
if (response == null) { if (response == null) {
return true; return true;
} else { } else {
logSafe("MPIN generation returned error: $response", level: LogLevel.warning); logSafe("MPIN generation returned error: $response",
level: LogLevel.warning);
showAppSnackbar( showAppSnackbar(
title: "MPIN Operation Failed", title: "MPIN Operation Failed",
message: "Please check your inputs.", message: "Please check your inputs.",
@ -236,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;
@ -253,9 +255,12 @@ class MPINController extends GetxController {
try { try {
isLoading.value = true; isLoading.value = true;
final fcmToken = await FirebaseNotificationService().getFcmToken();
final response = await AuthService.verifyMpin( final response = await AuthService.verifyMpin(
mpin: enteredMPIN, mpin: enteredMPIN,
mpinToken: mpinToken, mpinToken: mpinToken,
fcmToken: fcmToken ?? '',
); );
isLoading.value = false; isLoading.value = false;
@ -264,15 +269,29 @@ 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", level: LogLevel.warning); logSafe("MPIN verification failed: $errorMessage",
level: LogLevel.warning);
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: errorMessage, message: errorMessage,
@ -284,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,279 +0,0 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:marco/controller/my_controller.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:marco/helpers/services/app_logger.dart';
enum Gender {
male,
female,
other;
const Gender();
}
class AddEmployeeController extends MyController {
List<PlatformFile> files = [];
final MyFormValidator basicValidator = MyFormValidator();
Gender? selectedGender;
List<Map<String, dynamic>> roles = [];
String? selectedRoleId;
final List<Map<String, String>> countries = [
{"code": "+91", "name": "India"},
{"code": "+1", "name": "USA"},
{"code": "+971", "name": "UAE"},
{"code": "+44", "name": "UK"},
{"code": "+81", "name": "Japan"},
{"code": "+61", "name": "Australia"},
{"code": "+49", "name": "Germany"},
{"code": "+33", "name": "France"},
{"code": "+86", "name": "China"},
];
final Map<String, int> minDigitsPerCountry = {
"+91": 10,
"+1": 10,
"+971": 9,
"+44": 10,
"+81": 10,
"+61": 9,
"+49": 10,
"+33": 9,
"+86": 11,
};
final Map<String, int> maxDigitsPerCountry = {
"+91": 10,
"+1": 10,
"+971": 9,
"+44": 11,
"+81": 10,
"+61": 9,
"+49": 11,
"+33": 9,
"+86": 11,
};
String selectedCountryCode = "+91";
bool showOnline = true;
final List<String> categories = [];
@override
void onInit() {
super.onInit();
logSafe("Initializing AddEmployeeController...");
_initializeFields();
fetchRoles();
}
void _initializeFields() {
basicValidator.addField(
'first_name',
label: "First Name",
required: true,
controller: TextEditingController(),
);
basicValidator.addField(
'phone_number',
label: "Phone Number",
required: true,
controller: TextEditingController(),
);
basicValidator.addField(
'last_name',
label: "Last Name",
required: true,
controller: TextEditingController(),
);
logSafe("Fields initialized for first_name, phone_number, last_name.");
}
void onGenderSelected(Gender? gender) {
selectedGender = gender;
logSafe("Gender selected: ${gender?.name}");
update();
}
Future<void> fetchRoles() async {
logSafe("Fetching roles...");
try {
final result = await ApiService.getRoles();
if (result != null) {
roles = List<Map<String, dynamic>>.from(result);
logSafe("Roles fetched successfully.");
update();
} else {
logSafe("Failed to fetch roles: null result", level: LogLevel.error);
}
} catch (e, st) {
logSafe("Error fetching roles",
level: LogLevel.error, error: e, stackTrace: st);
}
}
void onRoleSelected(String? roleId) {
selectedRoleId = roleId;
logSafe("Role selected: $roleId");
update();
}
Future<Map<String, dynamic>?> createEmployees() async {
logSafe("Starting employee creation...");
if (selectedGender == null || selectedRoleId == null) {
logSafe("Missing gender or role.", level: LogLevel.warning);
showAppSnackbar(
title: "Missing Fields",
message: "Please select both Gender and Role.",
type: SnackbarType.warning,
);
return null;
}
final firstName = basicValidator.getController("first_name")?.text.trim();
final lastName = basicValidator.getController("last_name")?.text.trim();
final phoneNumber =
basicValidator.getController("phone_number")?.text.trim();
try {
final response = await ApiService.createEmployee(
firstName: firstName!,
lastName: lastName!,
phoneNumber: phoneNumber!,
gender: selectedGender!.name,
jobRoleId: selectedRoleId!,
);
logSafe("Response: $response");
if (response != null && response['success'] == true) {
logSafe("Employee created successfully.");
showAppSnackbar(
title: "Success",
message: "Employee created successfully!",
type: SnackbarType.success,
);
return response;
} else {
logSafe("Failed to create employee (response false)",
level: LogLevel.error);
}
} catch (e, st) {
logSafe("Error creating employee",
level: LogLevel.error, error: e, stackTrace: st);
}
showAppSnackbar(
title: "Error",
message: "Failed to create employee.",
type: SnackbarType.error,
);
return null;
}
Future<bool> _checkAndRequestContactsPermission() async {
final status = await Permission.contacts.request();
if (status.isGranted) return true;
if (status.isPermanentlyDenied) {
await openAppSettings();
}
showAppSnackbar(
title: "Permission Required",
message:
"Please allow Contacts permission from settings to pick a contact.",
type: SnackbarType.warning,
);
return false;
}
Future<void> pickContact(BuildContext context) async {
final permissionGranted = await _checkAndRequestContactsPermission();
if (!permissionGranted) return;
try {
final picked = await FlutterContacts.openExternalPick();
if (picked == null) return;
final contact =
await FlutterContacts.getContact(picked.id, withProperties: true);
if (contact == null) {
showAppSnackbar(
title: "Error",
message: "Failed to load contact details.",
type: SnackbarType.error,
);
return;
}
if (contact.phones.isEmpty) {
showAppSnackbar(
title: "No Phone Number",
message: "Selected contact has no phone number.",
type: SnackbarType.warning,
);
return;
}
final indiaPhones = contact.phones.where((p) {
final normalized = p.number.replaceAll(RegExp(r'[^0-9+]'), '');
return normalized.startsWith('+91') ||
RegExp(r'^\d{10}$').hasMatch(normalized);
}).toList();
if (indiaPhones.isEmpty) {
showAppSnackbar(
title: "No Indian Number",
message: "Selected contact has no Indian (+91) phone number.",
type: SnackbarType.warning,
);
return;
}
String? selectedPhone;
if (indiaPhones.length == 1) {
selectedPhone = indiaPhones.first.number;
} else {
selectedPhone = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text("Choose an Indian number"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: indiaPhones
.map((p) => ListTile(
title: Text(p.number),
onTap: () => Navigator.of(ctx).pop(p.number),
))
.toList(),
),
),
);
if (selectedPhone == null) return;
}
final normalizedPhone = selectedPhone.replaceAll(RegExp(r'[^0-9]'), '');
final phoneWithoutCountryCode = normalizedPhone.length > 10
? normalizedPhone.substring(normalizedPhone.length - 10)
: normalizedPhone;
basicValidator.getController('phone_number')?.text =
phoneWithoutCountryCode;
update();
} catch (e, st) {
logSafe("Error fetching contacts",
level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar(
title: "Error",
message: "Failed to fetch contacts.",
type: SnackbarType.error,
);
}
}
}

View File

@ -1,294 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_image_compressor.dart';
import 'package:marco/model/attendance_model.dart';
import 'package:marco/model/project_model.dart';
import 'package:marco/model/employee_model.dart';
import 'package:marco/model/attendance_log_model.dart';
import 'package:marco/model/regularization_log_model.dart';
import 'package:marco/model/attendance_log_view_model.dart';
import 'package:marco/controller/project_controller.dart';
class AttendanceController extends GetxController {
List<AttendanceModel> attendances = [];
List<ProjectModel> projects = [];
List<EmployeeModel> employees = [];
List<AttendanceLogModel> attendanceLogs = [];
List<RegularizationLogModel> regularizationLogs = [];
List<AttendanceLogViewModel> attendenceLogsView = [];
String selectedTab = 'Employee List';
DateTime? startDateAttendance;
DateTime? endDateAttendance;
RxBool isLoading = true.obs;
RxBool isLoadingProjects = true.obs;
RxBool isLoadingEmployees = true.obs;
RxBool isLoadingAttendanceLogs = true.obs;
RxBool isLoadingRegularizationLogs = true.obs;
RxBool isLoadingLogView = true.obs;
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
@override
void onInit() {
super.onInit();
_initializeDefaults();
}
void _initializeDefaults() {
_setDefaultDateRange();
fetchProjects();
}
void _setDefaultDateRange() {
final today = DateTime.now();
startDateAttendance = today.subtract(const Duration(days: 7));
endDateAttendance = today.subtract(const Duration(days: 1));
logSafe("Default date range set: $startDateAttendance to $endDateAttendance");
}
Future<bool> _handleLocationPermission() async {
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
logSafe('Location permissions are denied', level: LogLevel.warning);
return false;
}
}
if (permission == LocationPermission.deniedForever) {
logSafe('Location permissions are permanently denied', level: LogLevel.error);
return false;
}
return true;
}
Future<void> fetchProjects() async {
isLoadingProjects.value = true;
isLoading.value = true;
final response = await ApiService.getProjects();
if (response != null && response.isNotEmpty) {
projects = response.map((json) => ProjectModel.fromJson(json)).toList();
logSafe("Projects fetched: ${projects.length}");
} else {
logSafe("Failed to fetch projects or no projects available.", level: LogLevel.error);
projects = [];
}
isLoadingProjects.value = false;
isLoading.value = false;
update(['attendance_dashboard_controller']);
}
Future<void> loadAttendanceData(String projectId) async {
await fetchEmployeesByProject(projectId);
await fetchAttendanceLogs(projectId);
await fetchRegularizationLogs(projectId);
await fetchProjectData(projectId);
}
Future<void> fetchProjectData(String? projectId) async {
if (projectId == null) return;
isLoading.value = true;
await Future.wait([
fetchEmployeesByProject(projectId),
fetchAttendanceLogs(projectId, dateFrom: startDateAttendance, dateTo: endDateAttendance),
fetchRegularizationLogs(projectId),
]);
isLoading.value = false;
logSafe("Project data fetched for project ID: $projectId");
}
Future<void> fetchEmployeesByProject(String? projectId) async {
if (projectId == null) return;
isLoadingEmployees.value = true;
final response = await ApiService.getEmployeesByProject(projectId);
if (response != null) {
employees = response.map((json) => EmployeeModel.fromJson(json)).toList();
for (var emp in employees) {
uploadingStates[emp.id] = false.obs;
}
logSafe("Employees fetched: ${employees.length} for project $projectId");
update();
} else {
logSafe("Failed to fetch employees for project $projectId", level: LogLevel.error);
}
isLoadingEmployees.value = false;
}
Future<bool> captureAndUploadAttendance(
String id,
String employeeId,
String projectId, {
String comment = "Marked via mobile app",
required int action,
bool imageCapture = true,
String? markTime,
}) async {
try {
uploadingStates[employeeId]?.value = true;
XFile? image;
if (imageCapture) {
image = await ImagePicker().pickImage(source: ImageSource.camera, imageQuality: 80);
if (image == null) {
logSafe("Image capture cancelled.", level: LogLevel.warning);
return false;
}
final 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);
}
final hasLocationPermission = await _handleLocationPermission();
if (!hasLocationPermission) return false;
final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
final imageName = imageCapture ? ApiService.generateImageName(employeeId, employees.length + 1) : "";
final result = await ApiService.uploadAttendanceImage(
id, employeeId, image, position.latitude, position.longitude,
imageName: imageName, projectId: projectId, comment: comment,
action: action, imageCapture: imageCapture, markTime: markTime,
);
logSafe("Attendance uploaded for $employeeId, action: $action");
return result;
} catch (e, stacktrace) {
logSafe("Error uploading attendance", level: LogLevel.error, error: e, stackTrace: stacktrace);
return false;
} finally {
uploadingStates[employeeId]?.value = false;
}
}
Future<void> selectDateRangeForAttendance(BuildContext context, AttendanceController controller) async {
final today = DateTime.now();
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2022),
lastDate: today.subtract(const Duration(days: 1)),
initialDateRange: DateTimeRange(
start: startDateAttendance ?? today.subtract(const Duration(days: 7)),
end: endDateAttendance ?? today.subtract(const Duration(days: 1)),
),
builder: (context, child) {
return Center(
child: SizedBox(
width: 400,
height: 500,
child: Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.light(
primary: const Color.fromARGB(255, 95, 132, 255),
onPrimary: Colors.white,
onSurface: Colors.teal.shade800,
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: Colors.teal),
),
dialogTheme: DialogThemeData(backgroundColor: Colors.white),
),
child: child!,
),
),
);
},
);
if (picked != null) {
startDateAttendance = picked.start;
endDateAttendance = picked.end;
logSafe("Date range selected: $startDateAttendance to $endDateAttendance");
await controller.fetchAttendanceLogs(
Get.find<ProjectController>().selectedProject?.id,
dateFrom: picked.start,
dateTo: picked.end,
);
}
}
Future<void> fetchAttendanceLogs(String? projectId, {DateTime? dateFrom, DateTime? dateTo}) async {
if (projectId == null) return;
isLoadingAttendanceLogs.value = true;
isLoading.value = true;
final response = await ApiService.getAttendanceLogs(projectId, dateFrom: dateFrom, dateTo: dateTo);
if (response != null) {
attendanceLogs = response.map((json) => AttendanceLogModel.fromJson(json)).toList();
logSafe("Attendance logs fetched: ${attendanceLogs.length}");
update();
} else {
logSafe("Failed to fetch attendance logs for project $projectId", level: LogLevel.error);
}
isLoadingAttendanceLogs.value = false;
isLoading.value = false;
}
Map<String, List<AttendanceLogModel>> groupLogsByCheckInDate() {
final groupedLogs = <String, List<AttendanceLogModel>>{};
for (var logItem in attendanceLogs) {
final checkInDate = logItem.checkIn != null
? DateFormat('dd MMM yyyy').format(logItem.checkIn!)
: 'Unknown';
groupedLogs.putIfAbsent(checkInDate, () => []);
groupedLogs[checkInDate]!.add(logItem);
}
final sortedEntries = groupedLogs.entries.toList()
..sort((a, b) {
if (a.key == 'Unknown') return 1;
if (b.key == 'Unknown') return -1;
final dateA = DateFormat('dd MMM yyyy').parse(a.key);
final dateB = DateFormat('dd MMM yyyy').parse(b.key);
return dateB.compareTo(dateA);
});
final sortedMap = Map<String, List<AttendanceLogModel>>.fromEntries(sortedEntries);
logSafe("Logs grouped and sorted by check-in date.");
return sortedMap;
}
Future<void> fetchRegularizationLogs(String? projectId, {DateTime? dateFrom, DateTime? dateTo}) async {
if (projectId == null) return;
isLoadingRegularizationLogs.value = true;
isLoading.value = true;
final response = await ApiService.getRegularizationLogs(projectId);
if (response != null) {
regularizationLogs = response.map((json) => RegularizationLogModel.fromJson(json)).toList();
logSafe("Regularization logs fetched: ${regularizationLogs.length}");
update();
} else {
logSafe("Failed to fetch regularization logs for project $projectId", level: LogLevel.error);
}
isLoadingRegularizationLogs.value = false;
isLoading.value = false;
}
Future<void> fetchLogsView(String? id) async {
if (id == null) return;
isLoadingLogView.value = true;
isLoading.value = true;
final response = await ApiService.getAttendanceLogView(id);
if (response != null) {
attendenceLogsView = response.map((json) => AttendanceLogViewModel.fromJson(json)).toList();
attendenceLogsView.sort((a, b) {
if (a.activityTime == null || b.activityTime == null) return 0;
return b.activityTime!.compareTo(a.activityTime!);
});
logSafe("Attendance log view fetched for ID: $id");
update();
} else {
logSafe("Failed to fetch attendance log view for ID $id", level: LogLevel.error);
}
isLoadingLogView.value = false;
isLoading.value = false;
}
}

View File

@ -1,122 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/project_model.dart';
import 'package:marco/model/daily_task_model.dart';
class DailyTaskController extends GetxController {
List<ProjectModel> projects = [];
String? selectedProjectId;
DateTime? startDateTask;
DateTime? endDateTask;
List<TaskModel> dailyTasks = [];
final RxSet<String> expandedDates = <String>{}.obs;
void toggleDate(String dateKey) {
if (expandedDates.contains(dateKey)) {
expandedDates.remove(dateKey);
} else {
expandedDates.add(dateKey);
}
}
RxBool isLoading = true.obs;
Map<String, List<TaskModel>> groupedDailyTasks = {};
@override
void onInit() {
super.onInit();
_initializeDefaults();
}
void _initializeDefaults() {
_setDefaultDateRange();
}
void _setDefaultDateRange() {
final today = DateTime.now();
startDateTask = today.subtract(const Duration(days: 7));
endDateTask = today;
logSafe(
"Default date range set: $startDateTask to $endDateTask",
level: LogLevel.info,
);
}
Future<void> fetchTaskData(String? projectId) async {
if (projectId == null) {
logSafe("fetchTaskData: Skipped, projectId is null", level: LogLevel.warning);
return;
}
isLoading.value = true;
final response = await ApiService.getDailyTasks(
projectId,
dateFrom: startDateTask,
dateTo: endDateTask,
);
isLoading.value = false;
if (response != null) {
groupedDailyTasks.clear();
for (var taskJson in response) {
final task = TaskModel.fromJson(taskJson);
final assignmentDateKey =
task.assignmentDate.toIso8601String().split('T')[0];
groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []).add(task);
}
dailyTasks = groupedDailyTasks.values.expand((list) => list).toList();
logSafe(
"Daily tasks fetched and grouped: ${dailyTasks.length} for project $projectId",
level: LogLevel.info,
);
update();
} else {
logSafe(
"Failed to fetch daily tasks for project $projectId",
level: LogLevel.error,
);
}
}
Future<void> selectDateRangeForTaskData(
BuildContext context,
DailyTaskController controller,
) async {
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2022),
lastDate: DateTime.now(),
initialDateRange: DateTimeRange(
start: startDateTask ?? DateTime.now().subtract(const Duration(days: 7)),
end: endDateTask ?? DateTime.now(),
),
);
if (picked == null) {
logSafe("Date range picker cancelled by user.", level: LogLevel.debug);
return;
}
startDateTask = picked.start;
endDateTask = picked.end;
logSafe(
"Date range selected: $startDateTask to $endDateTask",
level: LogLevel.info,
);
await controller.fetchTaskData(controller.selectedProjectId);
}
}

View File

@ -1,107 +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: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 {
// Observables // Dependencies
final RxList<Map<String, dynamic>> roleWiseData = <Map<String, dynamic>>[].obs; final ProjectController projectController = Get.put(ProjectController());
final RxBool isLoading = false.obs;
final RxString selectedRange = '15D'.obs;
final RxBool isChartView = true.obs;
// Inject the ProjectController // =========================
final ProjectController projectController = Get.find<ProjectController>(); // 1. STATE VARIABLES
// =========================
// Attendance
final roleWiseData = <Map<String, dynamic>>[].obs;
final attendanceSelectedRange = '15D'.obs;
final attendanceIsChartView = true.obs;
final isAttendanceLoading = false.obs;
// Project Progress
final projectChartData = <ChartTaskData>[].obs;
final projectSelectedRange = '15D'.obs;
final projectIsChartView = true.obs;
final isProjectLoading = false.obs;
// Overview Counts
final totalProjects = 0.obs;
final ongoingProjects = 0.obs;
final isProjectsLoading = false.obs;
final totalTasks = 0.obs;
final completedTasks = 0.obs;
final isTasksLoading = false.obs;
final totalEmployees = 0.obs;
final inToday = 0.obs;
final isTeamsLoading = false.obs;
// Expenses & Reports
final isPendingExpensesLoading = false.obs;
final pendingExpensesData = Rx<PendingExpensesData?>(null);
final isExpenseTypeReportLoading = false.obs;
final expenseTypeReportData = Rx<ExpenseTypeReportData?>(null);
final expenseReportStartDate =
DateTime.now().subtract(const Duration(days: 15)).obs;
final 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'];
static const _rangeDaysMap = {
'7D': 7,
'15D': 15,
'30D': 30,
'3M': 90,
'6M': 180
};
// =========================
// 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,
);
if (projectController.selectedProjectId.value.isNotEmpty) {
fetchRoleWiseAttendance();
}
// React to project change
ever<String>(projectController.selectedProjectId, (id) { ever<String>(projectController.selectedProjectId, (id) {
if (id.isNotEmpty) { if (id.isNotEmpty) {
logSafe('Project changed to $id, fetching attendance', level: LogLevel.info, ); fetchAllDashboardData();
fetchRoleWiseAttendance(); fetchTodaysAttendance(id);
} }
}); });
// React to range change // Expense Report Date Listener
ever(selectedRange, (_) { everAll([expenseReportStartDate, expenseReportEndDate], (_) {
fetchRoleWiseAttendance(); if (projectController.selectedProjectId.value.isNotEmpty) {
fetchExpenseTypeReport(
startDate: expenseReportStartDate.value,
endDate: expenseReportEndDate.value,
);
}
});
// Chart Range Listeners
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
ever(projectSelectedRange, (_) => fetchProjectProgress());
}
// =========================
// 4. USER ACTIONS
// =========================
void updateAttendanceRange(String range) =>
attendanceSelectedRange.value = range;
void updateProjectRange(String range) => projectSelectedRange.value = range;
void toggleAttendanceChartView(bool isChart) =>
attendanceIsChartView.value = isChart;
void toggleProjectChartView(bool isChart) =>
projectIsChartView.value = isChart;
void updateSelectedExpenseType(ExpenseTypeModel? type) {
selectedExpenseType.value = type;
fetchMonthlyExpenses(categoryId: type?.id);
}
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;
}
}
Future<void> fetchAllDashboardData() async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
await Future.wait([
fetchRoleWiseAttendance(),
fetchProjectProgress(),
fetchDashboardTasks(projectId: projectId),
fetchDashboardTeams(projectId: projectId),
fetchPendingExpenses(),
fetchExpenseTypeReport(
startDate: expenseReportStartDate.value,
endDate: expenseReportEndDate.value,
),
fetchMonthlyExpenses(),
fetchMasterData(),
fetchCollectionOverview(),
fetchPurchaseInvoiceOverview(),
]);
}
Future<void> fetchCollectionOverview() async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
await _executeApiCall(isCollectionOverviewLoading, () async {
final response =
await ApiService.getCollectionOverview(projectId: projectId);
collectionOverviewData.value =
(response?.success == true) ? response!.data : null;
}); });
} }
int get rangeDays => _getDaysFromRange(selectedRange.value); Future<void> fetchTodaysAttendance(String projectId) async {
await _executeApiCall(isLoadingEmployees, () async {
int _getDaysFromRange(String range) { final response = await ApiService.getAttendanceForDashboard(projectId);
switch (range) { if (response != null) {
case '15D': employees.value = response;
return 15; for (var emp in employees) {
case '30D': uploadingStates.putIfAbsent(emp.id, () => false.obs);
return 30;
case '7D':
default:
return 7;
} }
} }
});
void updateRange(String range) {
selectedRange.value = range;
logSafe('Selected range updated to $range', level: LogLevel.debug);
} }
void toggleChartView(bool isChart) { Future<void> fetchMasterData() async {
isChartView.value = isChart; try {
logSafe('Chart view toggled to: $isChart', level: LogLevel.debug); final data = await ApiService.getMasterExpenseTypes();
if (data is List) {
expenseTypes.value =
data.map((e) => ExpenseTypeModel.fromJson(e)).toList();
}
} catch (_) {}
} }
Future<void> refreshDashboard() async { Future<void> fetchMonthlyExpenses({String? categoryId}) async {
logSafe('Manual dashboard refresh triggered.', level: LogLevel.debug); await _executeApiCall(isMonthlyExpenseLoading, () async {
await fetchRoleWiseAttendance(); 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 { Future<void> fetchRoleWiseAttendance() async {
final String projectId = projectController.selectedProjectId.value; final id = projectController.selectedProjectId.value;
if (id.isEmpty) return;
if (projectId.isEmpty) { await _executeApiCall(isAttendanceLoading, () async {
logSafe('Project ID is empty, skipping API call.', level: LogLevel.warning); final response = await ApiService.getDashboardAttendanceOverview(
return; id, getAttendanceDays());
}
try {
isLoading.value = true;
final List<dynamic>? response =
await ApiService.getDashboardAttendanceOverview(projectId, rangeDays);
if (response != null) {
roleWiseData.value = roleWiseData.value =
response.map((e) => Map<String, dynamic>.from(e)).toList(); response?.map((e) => Map<String, dynamic>.from(e)).toList() ?? [];
logSafe('Attendance overview fetched successfully.', level: LogLevel.info); });
} else {
roleWiseData.clear();
logSafe('Failed to fetch attendance overview: response is null.', level: LogLevel.error);
} }
} catch (e, st) {
roleWiseData.clear(); Future<void> fetchExpenseTypeReport(
logSafe( {required DateTime startDate, required DateTime endDate}) async {
'Error fetching attendance overview', final id = projectController.selectedProjectId.value;
level: LogLevel.error, if (id.isEmpty) return;
error: e,
stackTrace: st, await _executeApiCall(isExpenseTypeReportLoading, () async {
final response = await ApiService.getExpenseTypeReportApi(
projectId: id,
startDate: startDate,
endDate: endDate,
); );
} finally { expenseTypeReportData.value =
isLoading.value = false; (response?.success == true) ? response!.data : null;
});
} }
Future<void> fetchProjectProgress() async {
final id = projectController.selectedProjectId.value;
if (id.isEmpty) return;
await _executeApiCall(isProjectLoading, () async {
final response = await ApiService.getProjectProgress(
projectId: id, days: getProjectDays());
if (response?.success == true) {
projectChartData.value = response!.data
.map((d) => ChartTaskData.fromProjectData(d))
.toList();
} else {
projectChartData.clear();
}
});
}
Future<void> fetchDashboardTasks({required String projectId}) async {
await _executeApiCall(isTasksLoading, () async {
final response = await ApiService.getDashboardTasks(projectId: projectId);
if (response?.success == true) {
totalTasks.value = response!.data?.totalTasks ?? 0;
completedTasks.value = response.data?.completedTasks ?? 0;
} else {
totalTasks.value = 0;
completedTasks.value = 0;
}
});
}
Future<void> fetchDashboardTeams({required String projectId}) async {
await _executeApiCall(isTeamsLoading, () async {
final response = await ApiService.getDashboardTeams(projectId: projectId);
if (response?.success == true) {
totalEmployees.value = response!.data?.totalEmployees ?? 0;
inToday.value = response.data?.inToday ?? 0;
} else {
totalEmployees.value = 0;
inToday.value = 0;
}
});
} }
} }
enum MonthlyExpenseDuration {
oneMonth,
threeMonths,
sixMonths,
twelveMonths,
all,
}

View File

@ -1,211 +0,0 @@
import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/attendance_model.dart';
import 'package:marco/model/project_model.dart';
import 'package:marco/model/employee_model.dart';
import 'package:marco/model/employees/employee_details_model.dart';
import 'package:marco/controller/project_controller.dart';
class EmployeesScreenController extends GetxController {
List<AttendanceModel> attendances = [];
List<ProjectModel> projects = [];
String? selectedProjectId;
List<EmployeeDetailsModel> employeeDetails = [];
RxBool isAllEmployeeSelected = false.obs;
RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
RxBool isLoading = false.obs;
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
Rxn<EmployeeDetailsModel> selectedEmployeeDetails =
Rxn<EmployeeDetailsModel>();
RxBool isLoadingEmployeeDetails = false.obs;
@override
void onInit() {
super.onInit();
isLoading.value = true;
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']);
}
Future<void> fetchAllEmployees() async {
isLoading.value = true;
update(['employee_screen_controller']);
await _handleApiCall(
ApiService.getAllEmployees,
onSuccess: (data) {
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
logSafe("All Employees fetched: ${employees.length} employees loaded.",
level: LogLevel.info);
},
onEmpty: () {
employees.clear();
logSafe("No Employee data found or API call failed.",
level: LogLevel.warning);
},
);
isLoading.value = false;
update(['employee_screen_controller']);
}
Future<void> fetchEmployeesByProject(String? projectId) async {
if (projectId == null || projectId.isEmpty) {
logSafe("Project ID is required but was null or empty.",
level: LogLevel.error);
return;
}
isLoading.value = true;
await _handleApiCall(
() => ApiService.getAllEmployeesByProject(projectId),
onSuccess: (data) {
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
for (var emp in employees) {
uploadingStates[emp.id] = false.obs;
}
logSafe(
"Employees fetched: ${employees.length} for project $projectId",
level: LogLevel.info,
);
},
onEmpty: () {
employees.clear();
logSafe(
"No employees found for project $projectId.",
level: LogLevel.warning,
);
},
onError: (e) {
logSafe(
"Error fetching employees for project $projectId",
level: LogLevel.error,
error: e,
);
},
);
isLoading.value = false;
update(['employee_screen_controller']);
}
Future<void> fetchEmployeeDetails(String? employeeId) async {
if (employeeId == null || employeeId.isEmpty) return;
isLoadingEmployeeDetails.value = true;
await _handleSingleApiCall(
() => ApiService.getEmployeeDetails(employeeId),
onSuccess: (data) {
selectedEmployeeDetails.value = EmployeeDetailsModel.fromJson(data);
logSafe(
"Employee details loaded for $employeeId",
level: LogLevel.info,
);
},
onEmpty: () {
selectedEmployeeDetails.value = null;
logSafe(
"No employee details found for $employeeId",
level: LogLevel.warning,
);
},
onError: (e) {
selectedEmployeeDetails.value = null;
logSafe(
"Error fetching employee details for $employeeId",
level: LogLevel.error,
error: e,
);
},
);
isLoadingEmployeeDetails.value = false;
}
Future<void> _handleApiCall(
Future<List<dynamic>?> Function() apiCall, {
required Function(List<dynamic>) onSuccess,
required Function() onEmpty,
Function(dynamic error)? onError,
}) async {
try {
final response = await apiCall();
if (response != null && response.isNotEmpty) {
onSuccess(response);
} else {
onEmpty();
}
} catch (e) {
if (onError != null) {
onError(e);
} else {
logSafe("API call error", level: LogLevel.error, error: e);
}
}
}
Future<void> _handleSingleApiCall(
Future<Map<String, dynamic>?> Function() apiCall, {
required Function(Map<String, dynamic>) onSuccess,
required Function() onEmpty,
Function(dynamic error)? onError,
}) async {
try {
final response = await apiCall();
if (response != null && response.isNotEmpty) {
onSuccess(response);
} else {
onEmpty();
}
} catch (e) {
if (onError != null) {
onError(e);
} else {
logSafe("API call error", level: LogLevel.error, error: e);
}
}
}
}

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;
@ -24,6 +24,7 @@ class AddContactController extends GetxController {
final RxMap<String, String> tagsMap = <String, String>{}.obs; final RxMap<String, String> tagsMap = <String, String>{}.obs;
final RxBool isInitialized = false.obs; final RxBool isInitialized = false.obs;
final RxList<String> selectedProjects = <String>[].obs; final RxList<String> selectedProjects = <String>[].obs;
final RxBool isSubmitting = false.obs;
@override @override
void onInit() { void onInit() {
@ -49,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();
@ -93,21 +94,39 @@ class AddContactController extends GetxController {
required List<Map<String, String>> phones, required List<Map<String, String>> phones,
required String address, required String address,
required String description, required String description,
String? designation,
}) async { }) async {
if (isSubmitting.value) return;
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>()
.toList(); .toList();
// === Required validations only for name, organization, and bucket ===
if (name.trim().isEmpty) { if (name.trim().isEmpty) {
showAppSnackbar( showAppSnackbar(
title: "Missing Name", title: "Missing Name",
message: "Please enter the contact name.", message: "Please enter the contact name.",
type: SnackbarType.warning, type: SnackbarType.warning,
); );
isSubmitting.value = false;
return; return;
} }
@ -117,19 +136,20 @@ class AddContactController extends GetxController {
message: "Please enter the organization name.", message: "Please enter the organization name.",
type: SnackbarType.warning, type: SnackbarType.warning,
); );
isSubmitting.value = false;
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;
return; return;
} }
// === Build body (include optional fields if available) ===
try { try {
final tagObjects = enteredTags.map((tagName) { final tagObjects = enteredTags.map((tagName) {
final tagId = tagsMap[tagName]; final tagId = tagsMap[tagName];
@ -145,12 +165,14 @@ 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,
if (address.trim().isNotEmpty) "address": address.trim(), if (address.trim().isNotEmpty) "address": address.trim(),
if (description.trim().isNotEmpty) "description": description.trim(), if (description.trim().isNotEmpty) "description": description.trim(),
if (designation != null && designation.trim().isNotEmpty)
"designation": designation.trim(),
}; };
logSafe("${id != null ? 'Updating' : 'Creating'} contact"); logSafe("${id != null ? 'Updating' : 'Creating'} contact");
@ -182,6 +204,8 @@ class AddContactController extends GetxController {
message: "Something went wrong", message: "Something went wrong",
type: SnackbarType.error, type: SnackbarType.error,
); );
} finally {
isSubmitting.value = false;
} }
} }

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,29 +135,69 @@ class DirectoryController extends GetxController {
} }
} }
Future<void> fetchCommentsForContact(String contactId) async { Future<void> deleteComment(String commentId, String contactId) async {
try { try {
final data = await ApiService.getDirectoryComments(contactId); final success = await ApiService.restoreContactComment(commentId, false);
logSafe("Fetched comments for contact $contactId: $data");
final comments = if (success) {
data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? []; if (editingCommentId.value == commentId) editingCommentId.value = null;
await fetchCommentsForContact(contactId, active: true);
if (!contactCommentsMap.containsKey(contactId)) { await fetchCommentsForContact(contactId, active: false);
contactCommentsMap[contactId] = <DirectoryComment>[].obs; showAppSnackbar(
title: "Deleted",
message: "Comment deleted successfully.",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to delete comment.",
type: SnackbarType.error,
);
} }
} catch (e, stack) {
contactCommentsMap[contactId]!.assignAll(comments); logSafe("Delete comment failed: $e", level: LogLevel.error);
contactCommentsMap[contactId]?.refresh(); logSafe(stack.toString(), level: LogLevel.debug);
} catch (e) { showAppSnackbar(
logSafe("Error fetching comments for contact $contactId: $e", title: "Error",
level: LogLevel.error); message: "Something went wrong while deleting comment.",
type: SnackbarType.error,
contactCommentsMap[contactId] ??= <DirectoryComment>[].obs; );
contactCommentsMap[contactId]!.clear();
} }
} }
Future<void> restoreComment(String commentId, String contactId) async {
try {
final success = await ApiService.restoreContactComment(commentId, true);
if (success) {
await fetchCommentsForContact(contactId, active: true);
await fetchCommentsForContact(contactId, active: false);
showAppSnackbar(
title: "Restored",
message: "Comment restored successfully.",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to restore comment.",
type: SnackbarType.error,
);
}
} catch (e, stack) {
logSafe("Restore comment failed: $e", level: LogLevel.error);
logSafe(stack.toString(), level: LogLevel.debug);
showAppSnackbar(
title: "Error",
message: "Something went wrong while restoring comment.",
type: SnackbarType.error,
);
}
}
// -------------------- CONTACTS HANDLING --------------------
Future<void> fetchBuckets() async { Future<void> fetchBuckets() async {
try { try {
final response = await ApiService.getContactBucketList(); final response = await ApiService.getContactBucketList();
@ -135,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) {
@ -160,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();
} }
@ -182,19 +318,14 @@ class DirectoryController extends GetxController {
final bucketMatch = selectedBuckets.isEmpty || final bucketMatch = selectedBuckets.isEmpty ||
contact.bucketIds.any((id) => selectedBuckets.contains(id)); contact.bucketIds.any((id) => selectedBuckets.contains(id));
// Name, org, email, phone, tags
final nameMatch = contact.name.toLowerCase().contains(query); final nameMatch = contact.name.toLowerCase().contains(query);
final orgMatch = contact.organization.toLowerCase().contains(query); final orgMatch = contact.organization.toLowerCase().contains(query);
final emailMatch = contact.contactEmails final emailMatch = contact.contactEmails
.any((e) => e.emailAddress.toLowerCase().contains(query)); .any((e) => e.emailAddress.toLowerCase().contains(query));
final phoneMatch = contact.contactPhones final phoneMatch = contact.contactPhones
.any((p) => p.phoneNumber.toLowerCase().contains(query)); .any((p) => p.phoneNumber.toLowerCase().contains(query));
final tagMatch = final tagMatch =
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;
@ -218,6 +349,9 @@ class DirectoryController extends GetxController {
return categoryMatch && bucketMatch && searchMatch; return categoryMatch && bucketMatch && searchMatch;
}).toList(); }).toList();
filteredContacts
.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
} }
void toggleCategory(String categoryId) { void toggleCategory(String categoryId) {

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/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;
@ -107,6 +107,49 @@ class NotesController extends GetxController {
} }
} }
Future<void> restoreOrDeleteNote(NoteModel note,
{bool restore = true}) async {
final action = restore ? "restore" : "delete";
try {
logSafe("Attempting to $action note id: ${note.id}");
final success = await ApiService.restoreContactComment(
note.id,
restore, // true = restore, false = delete
);
if (success) {
final index = notesList.indexWhere((n) => n.id == note.id);
if (index != -1) {
notesList[index] = note.copyWith(isActive: restore);
notesList.refresh();
}
showAppSnackbar(
title: restore ? "Restored" : "Deleted",
message: restore
? "Note has been restored successfully."
: "Note has been deleted successfully.",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message:
restore ? "Failed to restore note." : "Failed to delete note.",
type: SnackbarType.error,
);
}
} catch (e, st) {
logSafe("$action note failed: $e", error: e, stackTrace: st);
showAppSnackbar(
title: "Error",
message: "Something went wrong while trying to $action the note.",
type: SnackbarType.error,
);
}
}
void addNote(NoteModel note) { void addNote(NoteModel note) {
notesList.insert(0, note); notesList.insert(0, note);
logSafe("Note added to list"); logSafe("Note added to list");

View File

@ -0,0 +1,82 @@
import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/model/document/document_details_model.dart';
import 'package:on_field_work/model/document/document_version_model.dart';
class DocumentDetailsController extends GetxController {
/// Observables
var isLoading = false.obs;
var documentDetails = Rxn<DocumentDetailsResponse>();
var versions = <DocumentVersionItem>[].obs;
var isVersionsLoading = false.obs;
// Loading states for buttons
var isVerifyLoading = false.obs;
var isRejectLoading = false.obs;
/// Fetch document details by id
Future<void> fetchDocumentDetails(String documentId) async {
try {
isLoading.value = true;
final response = await ApiService.getDocumentDetailsApi(documentId);
documentDetails.value = response;
} finally {
isLoading.value = false;
}
}
/// Fetch document versions by parentAttachmentId
Future<void> fetchDocumentVersions(String parentAttachmentId) async {
try {
isVersionsLoading.value = true;
final response = await ApiService.getDocumentVersionsApi(
parentAttachmentId: parentAttachmentId,
);
if (response != null) {
versions.assignAll(response.data.data);
} else {
versions.clear();
}
} finally {
isVersionsLoading.value = false;
}
}
/// Verify document
Future<bool> verifyDocument(String documentId) async {
try {
isVerifyLoading.value = true;
final result =
await ApiService.verifyDocumentApi(id: documentId, isVerify: true);
if (result) await fetchDocumentDetails(documentId);
return result;
} finally {
isVerifyLoading.value = false;
}
}
/// Reject document
Future<bool> rejectDocument(String documentId) async {
try {
isRejectLoading.value = true;
final result =
await ApiService.verifyDocumentApi(id: documentId, isVerify: false);
if (result) await fetchDocumentDetails(documentId);
return result;
} finally {
isRejectLoading.value = false;
}
}
/// Fetch Pre-Signed URL for a given version
Future<String?> fetchPresignedUrl(String versionId) async {
return await ApiService.getPresignedUrlApi(versionId);
}
/// Clear data when leaving the screen
void clearDetails() {
documentDetails.value = null;
versions.clear();
}
}

View File

@ -0,0 +1,239 @@
import 'package:flutter/material.dart';
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/document/master_document_type_model.dart';
import 'package:on_field_work/model/document/master_document_tags.dart';
import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
class DocumentUploadController extends GetxController {
// Observables
var isLoading = false.obs;
var isUploading = false.obs;
var categories = <DocumentType>[].obs;
var tags = <TagItem>[].obs;
DocumentType? selectedCategory;
/// --- FILE HANDLING ---
String? selectedFileName;
String? selectedFileBase64;
String? selectedFileContentType;
int? selectedFileSize;
/// --- TAG HANDLING ---
final tagCtrl = TextEditingController();
final enteredTags = <String>[].obs;
final filteredSuggestions = <String>[].obs;
var documentTypes = <DocumentType>[].obs;
DocumentType? selectedType;
@override
void onInit() {
super.onInit();
fetchCategories();
fetchTags();
}
/// Fetch available document categories
Future<void> fetchCategories() async {
try {
isLoading.value = true;
final response = await ApiService.getMasterDocumentTypesApi();
if (response != null && response.data.isNotEmpty) {
categories.assignAll(response.data);
logSafe("Fetched categories: ${categories.length}");
} else {
logSafe("No categories fetched", level: LogLevel.warning);
}
} finally {
isLoading.value = false;
}
}
Future<void> fetchDocumentTypes(String categoryId) async {
try {
isLoading.value = true;
final response =
await ApiService.getDocumentTypesByCategoryApi(categoryId);
if (response != null && response.data.isNotEmpty) {
documentTypes.assignAll(response.data);
selectedType = null; // reset previous type
} else {
documentTypes.clear();
selectedType = null;
}
} finally {
isLoading.value = false;
}
}
Future<String?> fetchPresignedUrl(String versionId) async {
return await ApiService.getPresignedUrlApi(versionId);
}
/// Fetch available document tags
Future<void> fetchTags() async {
try {
isLoading.value = true;
final response = await ApiService.getMasterDocumentTagsApi();
if (response != null) {
tags.assignAll(response.data);
logSafe("Fetched tags: ${tags.length}");
} else {
logSafe("No tags fetched", level: LogLevel.warning);
}
} finally {
isLoading.value = false;
}
}
/// --- TAG LOGIC ---
void filterSuggestions(String query) {
if (query.isEmpty) {
filteredSuggestions.clear();
return;
}
filteredSuggestions.assignAll(
tags.map((t) => t.name).where(
(tag) => tag.toLowerCase().contains(query.toLowerCase()),
),
);
}
void addEnteredTag(String tag) {
if (tag.trim().isEmpty) return;
if (!enteredTags.contains(tag.trim())) {
enteredTags.add(tag.trim());
}
}
void removeEnteredTag(String tag) {
enteredTags.remove(tag);
}
void clearSuggestions() {
filteredSuggestions.clear();
}
/// Upload document
Future<bool> uploadDocument({
required String documentId,
required String name,
required String entityId,
required String documentTypeId,
required String fileName,
required String base64Data,
required String contentType,
required int fileSize,
String? description,
}) async {
try {
isUploading.value = true;
final payloadTags =
enteredTags.map((t) => {"name": t, "isActive": true}).toList();
final payload = {
"documentId": documentId,
"name": name,
"description": description,
"entityId": entityId,
"documentTypeId": documentTypeId,
"fileName": fileName,
"base64Data":
base64Data.isNotEmpty ? "<base64-string-truncated>" : null,
"contentType": contentType,
"fileSize": fileSize,
"tags": payloadTags,
};
// Log the payload (hide long base64 string for readability)
logSafe("Upload payload: $payload");
final success = await ApiService.uploadDocumentApi(
documentId: documentId,
name: name,
description: description,
entityId: entityId,
documentTypeId: documentTypeId,
fileName: fileName,
base64Data: base64Data,
contentType: contentType,
fileSize: fileSize,
tags: payloadTags,
);
if (success) {
showAppSnackbar(
title: "Success",
message: "Document uploaded successfully",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Could not upload document",
type: SnackbarType.error,
);
}
return success;
} catch (e, stack) {
logSafe("Upload error: $e", level: LogLevel.error);
logSafe("Stacktrace: $stack", level: LogLevel.debug);
showAppSnackbar(
title: "Error",
message: "An unexpected error occurred",
type: SnackbarType.error,
);
return false;
} finally {
isUploading.value = false;
}
}
Future<bool> editDocument(Map<String, dynamic> payload) async {
try {
isUploading.value = true;
final attachment = payload["attachment"];
final success = await ApiService.editDocumentApi(
id: payload["id"],
name: payload["name"],
documentId: payload["documentId"],
description: payload["description"],
tags: (payload["tags"] as List).cast<Map<String, dynamic>>(),
attachment: attachment,
);
if (success) {
showAppSnackbar(
title: "Success",
message: "Document updated successfully",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to update document",
type: SnackbarType.error,
);
}
return success;
} catch (e, stack) {
logSafe("Edit error: $e", level: LogLevel.error);
logSafe("Stacktrace: $stack", level: LogLevel.debug);
showAppSnackbar(
title: "Error",
message: "An unexpected error occurred",
type: SnackbarType.error,
);
return false;
} finally {
isUploading.value = false;
}
}
}

View File

@ -0,0 +1,226 @@
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/model/document/document_filter_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 {
// ==================== Observables ====================
final isLoading = false.obs;
final documents = <DocumentItem>[].obs;
final filters = Rxn<DocumentFiltersData>();
// Selected filters (multi-select)
final selectedUploadedBy = <String>[].obs;
final selectedCategory = <String>[].obs;
final selectedType = <String>[].obs;
final selectedTag = <String>[].obs;
// Pagination
final pageNumber = 1.obs;
final pageSize = 20;
final hasMore = true.obs;
// Error handling
final errorMessage = ''.obs;
// Preferences
final showInactive = false.obs;
// Search
final searchQuery = ''.obs;
final searchController = TextEditingController();
// Additional filters
final isUploadedAt = true.obs;
final isVerified = RxnBool();
final startDate = Rxn<DateTime>();
final endDate = Rxn<DateTime>();
// ==================== 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 {
try {
final response = await ApiService.getDocumentFilters(entityTypeId);
if (response != null && response.success) {
filters.value = response.data;
} else {
errorMessage.value = response?.message ?? 'Failed to fetch filters';
_showError('Failed to load filters');
}
} catch (e) {
errorMessage.value = 'Error fetching filters: $e';
_showError('Error loading filters');
debugPrint('❌ Error fetching filters: $e');
}
}
/// Toggle document active/inactive state
Future<bool> toggleDocumentActive(
String id, {
required bool isActive,
required String entityTypeId,
required String entityId,
}) async {
try {
isLoading.value = true;
final success = await ApiService.deleteDocumentApi(
id: id,
isActive: isActive,
);
if (success) {
// Refresh list after state change
await fetchDocuments(
entityTypeId: entityTypeId,
entityId: entityId,
reset: true,
);
// Show success snackbar
showAppSnackbar(
title: 'Success',
message: isActive ? 'Document deactivated' : 'Document activated',
type: SnackbarType.success,
);
return true;
} else {
errorMessage.value = 'Failed to update document state';
_showError('Failed to update document state');
return false;
}
} catch (e) {
errorMessage.value = 'Error updating document: $e';
_showError('Error updating document: $e');
debugPrint('❌ Error toggling document state: $e');
return false;
} finally {
isLoading.value = false;
}
}
/// Fetch documents for entity with pagination
Future<void> fetchDocuments({
required String entityTypeId,
required String entityId,
String? filter,
String? searchString,
bool reset = false,
}) async {
try {
if (reset) {
pageNumber.value = 1;
documents.clear();
hasMore.value = true;
}
if (!hasMore.value && !reset) return;
if (isLoading.value) return;
isLoading.value = true;
final response = await ApiService.getDocumentListApi(
entityTypeId: entityTypeId,
entityId: entityId,
filter: filter ?? '',
searchString: searchString ?? searchQuery.value,
pageNumber: pageNumber.value,
pageSize: pageSize,
isActive: !showInactive.value,
);
if (response != null && response.success) {
if (response.data?.data.isNotEmpty ?? false) {
documents.addAll(response.data!.data);
pageNumber.value++;
} else {
hasMore.value = false;
}
errorMessage.value = '';
} else {
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) {
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 {
isLoading.value = false;
}
}
// ==================== Helper Methods ====================
/// Clear all selected filters
void clearFilters() {
selectedUploadedBy.clear();
selectedCategory.clear();
selectedType.clear();
selectedTag.clear();
isUploadedAt.value = true;
isVerified.value = null;
startDate.value = null;
endDate.value = null;
}
/// Check if any filters are active
bool hasActiveFilters() {
return selectedUploadedBy.isNotEmpty ||
selectedCategory.isNotEmpty ||
selectedType.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

@ -0,0 +1,62 @@
import 'dart:async';
import 'package:get/get.dart';
import 'package:on_field_work/model/dynamicMenu/dynamic_menu_model.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
class DynamicMenuController extends GetxController {
// UI reactive states
final RxBool isLoading = false.obs;
final RxBool hasError = false.obs;
final RxString errorMessage = ''.obs;
final RxList<MenuItem> menuItems = <MenuItem>[].obs;
@override
void onInit() {
super.onInit();
// Fetch menus directly from API at startup
fetchMenu();
}
/// Fetch dynamic menu from API (no local cache)
Future<void> fetchMenu() async {
isLoading.value = true;
hasError.value = false;
errorMessage.value = '';
try {
final responseData = await ApiService.getMenuApi();
if (responseData != null) {
final menuResponse = MenuResponse.fromJson(responseData);
menuItems.assignAll(menuResponse.data);
logSafe("✅ Menu loaded from API with ${menuItems.length} items");
} else {
_handleApiFailure("Menu API returned null response");
}
} catch (e) {
_handleApiFailure("Menu fetch exception: $e");
} finally {
isLoading.value = false;
}
}
void _handleApiFailure(String logMessage) {
logSafe(logMessage, level: LogLevel.error);
// No cache available, show error state
hasError.value = true;
errorMessage.value = "❌ Unable to load menus. Please try again later.";
menuItems.clear();
}
bool isMenuAllowed(String menuName) {
final menu = menuItems.firstWhereOrNull((m) => m.name == menuName);
return menu?.available ?? false;
}
@override
void onClose() {
super.onClose();
}
}

View File

@ -0,0 +1,310 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:on_field_work/controller/my_controller.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/widgets/my_form_validator.dart';
import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:permission_handler/permission_handler.dart';
enum Gender {
male,
female,
other;
const Gender();
}
class AddEmployeeController extends MyController {
Map<String, dynamic>? editingEmployeeData;
// State
final MyFormValidator basicValidator = MyFormValidator();
final List<PlatformFile> files = [];
final List<String> categories = [];
Gender? selectedGender;
List<Map<String, dynamic>> roles = [];
String? selectedRoleId;
String selectedCountryCode = '+91';
bool showOnline = true;
DateTime? joiningDate;
String? selectedOrganizationId;
RxString selectedOrganizationName = RxString('');
@override
void onInit() {
super.onInit();
logSafe('Initializing AddEmployeeController...');
_initializeFields();
fetchRoles();
if (editingEmployeeData != null) {
prefillFields();
}
}
void _initializeFields() {
basicValidator.addField(
'first_name',
label: 'First Name',
required: true,
controller: TextEditingController(),
);
basicValidator.addField(
'phone_number',
label: 'Phone Number',
required: true,
controller: TextEditingController(),
);
basicValidator.addField(
'last_name',
label: 'Last Name',
required: true,
controller: TextEditingController(),
);
// Email is optional in controller; UI enforces when application access is checked
basicValidator.addField(
'email',
label: 'Email',
required: false,
controller: TextEditingController(),
);
logSafe('Fields initialized for first_name, phone_number, last_name, email.');
}
// Prefill fields in edit mode
void prefillFields() {
logSafe('Prefilling data for editing...');
basicValidator.getController('first_name')?.text =
editingEmployeeData?['first_name'] ?? '';
basicValidator.getController('last_name')?.text =
editingEmployeeData?['last_name'] ?? '';
basicValidator.getController('phone_number')?.text =
editingEmployeeData?['phone_number'] ?? '';
selectedGender = editingEmployeeData?['gender'] != null
? Gender.values.firstWhereOrNull((g) => g.name == editingEmployeeData!['gender'])
: null;
basicValidator.getController('email')?.text =
editingEmployeeData?['email'] ?? '';
selectedRoleId = editingEmployeeData?['job_role_id'];
if (editingEmployeeData?['joining_date'] != null) {
joiningDate = DateTime.tryParse(editingEmployeeData!['joining_date']);
}
update();
}
void setJoiningDate(DateTime date) {
joiningDate = date;
logSafe('Joining date selected: $date');
update();
}
void onGenderSelected(Gender? gender) {
selectedGender = gender;
logSafe('Gender selected: ${gender?.name}');
update();
}
Future<void> fetchRoles() async {
logSafe('Fetching roles...');
try {
final result = await ApiService.getRoles();
if (result != null) {
roles = List<Map<String, dynamic>>.from(result);
logSafe('Roles fetched successfully.');
update();
} else {
logSafe('Failed to fetch roles: null result', level: LogLevel.error);
}
} catch (e, st) {
logSafe('Error fetching roles', level: LogLevel.error, error: e, stackTrace: st);
}
}
void onRoleSelected(String? roleId) {
selectedRoleId = roleId;
logSafe('Role selected: $roleId');
update();
}
// Create or update employee
Future<Map<String, dynamic>?> createOrUpdateEmployee({
String? email,
bool hasApplicationAccess = false,
}) async {
logSafe(editingEmployeeData != null
? 'Starting employee update...'
: 'Starting employee creation...');
if (selectedGender == null || selectedRoleId == null) {
showAppSnackbar(
title: 'Missing Fields',
message: 'Please select both Gender and Role.',
type: SnackbarType.warning,
);
return null;
}
final firstName = basicValidator.getController('first_name')?.text.trim();
final lastName = basicValidator.getController('last_name')?.text.trim();
final phoneNumber = basicValidator.getController('phone_number')?.text.trim();
try {
// sanitize orgId before sending
final String? orgId = (selectedOrganizationId != null &&
selectedOrganizationId!.trim().isNotEmpty)
? selectedOrganizationId
: null;
final response = await ApiService.createEmployee(
id: editingEmployeeData?['id'],
firstName: firstName!,
lastName: lastName!,
phoneNumber: phoneNumber!,
gender: selectedGender!.name,
jobRoleId: selectedRoleId!,
joiningDate: joiningDate?.toIso8601String() ?? '',
organizationId: orgId,
email: email,
hasApplicationAccess: hasApplicationAccess,
);
logSafe('Response: $response');
if (response != null && response['success'] == true) {
showAppSnackbar(
title: 'Success',
message: editingEmployeeData != null
? 'Employee updated successfully!'
: 'Employee created successfully!',
type: SnackbarType.success,
);
return response;
} else {
logSafe('Failed operation', level: LogLevel.error);
}
} catch (e, st) {
logSafe('Error creating/updating employee',
level: LogLevel.error, error: e, stackTrace: st);
}
showAppSnackbar(
title: 'Error',
message: 'Failed to save employee.',
type: SnackbarType.error,
);
return null;
}
Future<bool> _checkAndRequestContactsPermission() async {
final status = await Permission.contacts.request();
if (status.isGranted) return true;
if (status.isPermanentlyDenied) {
await openAppSettings();
}
showAppSnackbar(
title: 'Permission Required',
message: 'Please allow Contacts permission from settings to pick a contact.',
type: SnackbarType.warning,
);
return false;
}
Future<void> pickContact(BuildContext context) async {
final permissionGranted = await _checkAndRequestContactsPermission();
if (!permissionGranted) return;
try {
final picked = await FlutterContacts.openExternalPick();
if (picked == null) return;
final contact =
await FlutterContacts.getContact(picked.id, withProperties: true);
if (contact == null) {
showAppSnackbar(
title: 'Error',
message: 'Failed to load contact details.',
type: SnackbarType.error,
);
return;
}
if (contact.phones.isEmpty) {
showAppSnackbar(
title: 'No Phone Number',
message: 'Selected contact has no phone number.',
type: SnackbarType.warning,
);
return;
}
final indiaPhones = contact.phones.where((p) {
final normalized = p.number.replaceAll(RegExp(r'[^0-9+]'), '');
return normalized.startsWith('+91') ||
RegExp(r'^\d{10}$').hasMatch(normalized);
}).toList();
if (indiaPhones.isEmpty) {
showAppSnackbar(
title: 'No Indian Number',
message: 'Selected contact has no Indian (+91) phone number.',
type: SnackbarType.warning,
);
return;
}
String? selectedPhone;
if (indiaPhones.length == 1) {
selectedPhone = indiaPhones.first.number;
} else {
selectedPhone = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Choose an Indian number'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: indiaPhones
.map(
(p) => ListTile(
title: Text(p.number),
onTap: () => Navigator.of(ctx).pop(p.number),
),
)
.toList(),
),
),
);
if (selectedPhone == null) return;
}
final normalizedPhone = selectedPhone.replaceAll(RegExp(r'[^0-9]'), '');
final phoneWithoutCountryCode = normalizedPhone.length > 10
? normalizedPhone.substring(normalizedPhone.length - 10)
: normalizedPhone;
basicValidator.getController('phone_number')?.text =
phoneWithoutCountryCode;
update();
} catch (e, st) {
logSafe('Error fetching contacts',
level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar(
title: 'Error',
message: 'Failed to fetch contacts.',
type: SnackbarType.error,
);
}
}
}

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

@ -0,0 +1,193 @@
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/employees/employee_model.dart';
import 'package:on_field_work/model/employees/employee_details_model.dart';
class EmployeesScreenController extends GetxController {
/// Data lists
RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
Rxn<EmployeeDetailsModel> selectedEmployeeDetails =
Rxn<EmployeeDetailsModel>();
/// Loading states
RxBool isLoading = 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
void onInit() {
super.onInit();
fetchAllEmployees();
}
/// 🔹 Fetch all employees (no project filter)
Future<void> fetchAllEmployees({String? organizationId}) async {
isLoading.value = true;
update(['employee_screen_controller']);
await _handleApiCall(
() => ApiService.getAllEmployees(organizationId: organizationId),
onSuccess: (data) {
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
logSafe(
"All Employees fetched: ${employees.length} employees loaded.",
level: LogLevel.info,
);
// Reset selection states when new data arrives
selectedEmployeeIds.clear();
isAllEmployeeSelected.value = false;
},
onEmpty: () {
employees.clear();
selectedEmployeeIds.clear();
isAllEmployeeSelected.value = false;
logSafe("No Employee data found or API call failed",
level: LogLevel.warning);
},
);
isLoading.value = false;
update(['employee_screen_controller']);
}
/// 🔹 Fetch details for a specific employee
Future<void> fetchEmployeeDetails(String? employeeId) async {
if (employeeId == null || employeeId.isEmpty) return;
isLoadingEmployeeDetails.value = true;
await _handleSingleApiCall(
() => ApiService.getEmployeeDetails(employeeId),
onSuccess: (data) {
selectedEmployeeDetails.value = EmployeeDetailsModel.fromJson(data);
logSafe("Employee details loaded for $employeeId",
level: LogLevel.info);
},
onEmpty: () {
selectedEmployeeDetails.value = null;
logSafe("No employee details found for $employeeId",
level: LogLevel.warning);
},
onError: (e) {
selectedEmployeeDetails.value = null;
logSafe("Error fetching employee details for $employeeId",
level: LogLevel.error, error: e);
},
);
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<List<dynamic>?> Function() apiCall, {
required Function(List<dynamic>) onSuccess,
required Function() onEmpty,
Function(dynamic error)? onError,
}) async {
try {
final response = await apiCall();
if (response != null && response.isNotEmpty) {
onSuccess(response);
} else {
onEmpty();
}
} catch (e) {
if (onError != null) {
onError(e);
} else {
logSafe("API call error", level: LogLevel.error, error: e);
}
}
}
/// 🔹 Generic handler for single-object API responses
Future<void> _handleSingleApiCall(
Future<Map<String, dynamic>?> Function() apiCall, {
required Function(Map<String, dynamic>) onSuccess,
required Function() onEmpty,
Function(dynamic error)? onError,
}) async {
try {
final response = await apiCall();
if (response != null && response.isNotEmpty) {
onSuccess(response);
} else {
onEmpty();
}
} catch (e) {
if (onError != null) {
onError(e);
} else {
logSafe("API call error", level: LogLevel.error, error: e);
}
}
}
}

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

@ -0,0 +1,562 @@
import 'dart:convert';
import 'dart:io';
import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.dart';
import 'package:mime/mime.dart';
import 'package:image_picker/image_picker.dart';
import 'package:on_field_work/controller/expense/expense_screen_controller.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/model/expense/expense_type_model.dart';
import 'package:on_field_work/model/expense/payment_types_model.dart';
import 'package:on_field_work/helpers/widgets/time_stamp_image_helper.dart';
class AddExpenseController extends GetxController {
// --- Text Controllers ---
final controllers = <TextEditingController>[
TextEditingController(), // amount
TextEditingController(), // description
TextEditingController(), // supplier
TextEditingController(), // transactionId
TextEditingController(), // gst
TextEditingController(), // location
TextEditingController(), // transactionDate
TextEditingController(), // noOfPersons
TextEditingController(), // employeeSearch
];
TextEditingController get amountController => controllers[0];
TextEditingController get descriptionController => controllers[1];
TextEditingController get supplierController => controllers[2];
TextEditingController get transactionIdController => controllers[3];
TextEditingController get gstController => controllers[4];
TextEditingController get locationController => controllers[5];
TextEditingController get transactionDateController => controllers[6];
TextEditingController get noOfPersonsController => controllers[7];
TextEditingController get employeeSearchController => controllers[8];
// --- Reactive State ---
final isLoading = false.obs;
final isSubmitting = false.obs;
final isFetchingLocation = false.obs;
final isEditMode = false.obs;
final isSearchingEmployees = false.obs;
// --- Paid By (Single + Multi Selection Support) ---
// single selection
final selectedPaidBy = Rxn<EmployeeModel>();
// helper setters
void setSelectedPaidBy(EmployeeModel? emp) {
selectedPaidBy.value = emp;
}
// --- Dropdown Selections & Data ---
final selectedPaymentMode = Rxn<PaymentModeModel>();
final selectedExpenseType = Rxn<ExpenseTypeModel>();
// final selectedPaidBy = Rxn<EmployeeModel>();
final selectedProject = ''.obs;
final selectedTransactionDate = Rxn<DateTime>();
final attachments = <File>[].obs;
final existingAttachments = <Map<String, dynamic>>[].obs;
final globalProjects = <String>[].obs;
final projectsMap = <String, String>{}.obs;
final expenseTypes = <ExpenseTypeModel>[].obs;
final paymentModes = <PaymentModeModel>[].obs;
final allEmployees = <EmployeeModel>[].obs;
final employeeSearchResults = <EmployeeModel>[].obs;
final isProcessingAttachment = false.obs;
String? editingExpenseId;
final expenseController = Get.find<ExpenseController>();
final ImagePicker _picker = ImagePicker();
@override
void onInit() {
super.onInit();
loadMasterData();
employeeSearchController.addListener(
() => searchEmployees(employeeSearchController.text),
);
}
@override
void onClose() {
for (var c in controllers) {
c.dispose();
}
super.onClose();
}
// --- Employee Search ---
Future<void> searchEmployees(String query) async {
if (query.trim().isEmpty) return employeeSearchResults.clear();
isSearchingEmployees.value = true;
try {
final data = await ApiService.searchEmployeesBasic(
searchString: query.trim(),
);
if (data is List) {
employeeSearchResults.assignAll(
data
.map((e) => EmployeeModel.fromJson(e as Map<String, dynamic>))
.toList(),
);
} else {
employeeSearchResults.clear();
}
} catch (e) {
logSafe("Error searching employees: $e", level: LogLevel.error);
employeeSearchResults.clear();
} finally {
isSearchingEmployees.value = false;
}
}
// --- Form Population (Edit) ---
Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
isEditMode.value = true;
editingExpenseId = '${data['id']}';
selectedProject.value = data['projectName'] ?? '';
amountController.text = '${data['amount'] ?? ''}';
supplierController.text = data['supplerName'] ?? '';
descriptionController.text = data['description'] ?? '';
transactionIdController.text = data['transactionId'] ?? '';
locationController.text = data['location'] ?? '';
noOfPersonsController.text = '${data['noOfPersons'] ?? 0}';
_setTransactionDate(data['transactionDate']);
_setDropdowns(data);
await _setPaidBy(data);
_setAttachments(data['attachments']);
_logPrefilledData();
}
void _setTransactionDate(dynamic dateStr) {
if (dateStr == null) {
selectedTransactionDate.value = null;
transactionDateController.clear();
return;
}
try {
final parsed = DateTime.parse(dateStr);
selectedTransactionDate.value = parsed;
transactionDateController.text = DateFormat('dd-MM-yyyy').format(parsed);
} catch (_) {
selectedTransactionDate.value = null;
transactionDateController.clear();
}
}
void _setDropdowns(Map<String, dynamic> data) {
selectedExpenseType.value =
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
selectedPaymentMode.value =
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
}
Future<void> _setPaidBy(Map<String, dynamic> data) async {
final paidById = '${data['paidById']}';
selectedPaidBy.value =
allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
if (selectedPaidBy.value == null && data['paidByFirstName'] != null) {
await searchEmployees(
'${data['paidByFirstName']} ${data['paidByLastName']}',
);
selectedPaidBy.value = employeeSearchResults
.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
}
}
void _setAttachments(dynamic attachmentsData) {
existingAttachments.clear();
if (attachmentsData is List) {
existingAttachments.addAll(
List<Map<String, dynamic>>.from(attachmentsData).map(
(e) => {...e, 'isActive': true},
),
);
}
}
void _logPrefilledData() {
final info = [
'ID: $editingExpenseId',
'Project: ${selectedProject.value}',
'Amount: ${amountController.text}',
'Supplier: ${supplierController.text}',
'Description: ${descriptionController.text}',
'Transaction ID: ${transactionIdController.text}',
'Location: ${locationController.text}',
'Transaction Date: ${transactionDateController.text}',
'No. of Persons: ${noOfPersonsController.text}',
'Expense Category: ${selectedExpenseType.value?.name}',
'Payment Mode: ${selectedPaymentMode.value?.name}',
'Paid By: ${selectedPaidBy.value?.name}',
'Attachments: ${attachments.length}',
'Existing Attachments: ${existingAttachments.length}',
];
for (var line in info) {
logSafe(line, level: LogLevel.info);
}
}
// --- Pickers ---
Future<void> pickTransactionDate(BuildContext context) async {
final pickedDate = await showDatePicker(
context: context,
initialDate: selectedTransactionDate.value ?? DateTime.now(),
firstDate: DateTime(DateTime.now().year - 5),
lastDate: DateTime.now(),
);
if (pickedDate != null) {
final now = DateTime.now();
final finalDateTime = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
now.hour,
now.minute,
now.second,
);
selectedTransactionDate.value = finalDateTime;
transactionDateController.text =
DateFormat('dd MMM yyyy').format(finalDateTime);
}
}
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; // 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) {
_errorSnackbar("Camera error: $e");
} finally {
isProcessingAttachment.value = false; // stop loading
}
}
// --- Location ---
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;
}
// --- Data Fetching ---
Future<void> loadMasterData() async =>
Future.wait([fetchMasterData(), fetchGlobalProjects()]);
Future<void> fetchMasterData() async {
try {
final types = await ApiService.getMasterExpenseTypes();
if (types is List) {
expenseTypes.value = types
.map((e) => ExpenseTypeModel.fromJson(e as Map<String, dynamic>))
.toList();
}
final modes = await ApiService.getMasterPaymentModes();
if (modes is List) {
paymentModes.value = modes
.map((e) => PaymentModeModel.fromJson(e as Map<String, dynamic>))
.toList();
}
} catch (_) {
_errorSnackbar("Failed to fetch master data");
}
}
Future<void> fetchGlobalProjects() async {
try {
final response = await ApiService.getGlobalProjects();
if (response != null) {
final names = <String>[];
for (var item in response) {
final name = item['name']?.toString().trim();
final id = item['id']?.toString().trim();
if (name != null && id != null) {
projectsMap[name] = id;
names.add(name);
}
}
globalProjects.assignAll(names);
}
} catch (e) {
logSafe("Error fetching projects: $e", level: LogLevel.error);
}
}
// --- Submission ---
Future<void> submitOrUpdateExpense() async {
if (isSubmitting.value) return;
isSubmitting.value = true;
try {
final validationMsg = validateForm();
if (validationMsg.isNotEmpty) {
_errorSnackbar(validationMsg, "Missing Fields");
return;
}
final payload = await _buildExpensePayload();
final success = await _submitToApi(payload);
if (success) {
await expenseController.fetchExpenses();
Get.back();
showAppSnackbar(
title: "Success",
message:
"Expense ${isEditMode.value ? 'updated' : 'created'} successfully!",
type: SnackbarType.success,
);
} else {
_errorSnackbar("Operation failed. Try again.");
}
} catch (e) {
_errorSnackbar("Unexpected error: $e");
} finally {
isSubmitting.value = false;
}
}
Future<bool> _submitToApi(Map<String, dynamic>? payload) async {
if (payload == null) {
_errorSnackbar("Payload is empty. Cannot submit.");
return false;
}
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;
}
}
Future<Map<String, dynamic>?> _buildExpensePayload() async {
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
? existingAttachments
.map((e) => {
"documentId": e['documentId'],
"fileName": e['fileName'] ?? "",
"contentType": e['contentType'] ?? "",
"fileSize": 0,
"description": "",
"url": e['url'] ?? "",
"isActive": e['isActive'] ?? true,
"base64Data": "",
})
.toList()
: <Map<String, dynamic>>[];
// --- Process new attachments ---
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": "",
};
}),
);
// --- Build final payload ---
final payload = {
if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId,
"projectId": projectId,
"expenseCategoryId": expenseType.id,
"paymentModeId": paymentMode.id,
"paidById": paidBy.id,
"transactionDate":
(selectedTransactionDate.value ?? now).toUtc().toIso8601String(),
"transactionId": transactionIdController.text.trim(),
"description": descriptionController.text.trim(),
"location": locationController.text.trim(),
"supplerName": supplierController.text.trim(),
"amount": double.tryParse(amountController.text.trim()) ?? 0,
"noOfPersons": expenseType.noOfPersonsRequired == true
? int.tryParse(noOfPersonsController.text.trim()) ?? 0
: 0,
"billAttachments": [...existingPayload, ...newPayload].isEmpty
? null
: [...existingPayload, ...newPayload],
};
return payload;
}
String validateForm() {
final missing = <String>[];
if (selectedProject.value.isEmpty) missing.add("Project");
if (selectedExpenseType.value == null) missing.add("Expense Category");
if (selectedPaymentMode.value == null) missing.add("Payment Mode");
if (selectedPaidBy.value == null) missing.add("Paid By");
if (amountController.text.trim().isEmpty) missing.add("Amount");
if (descriptionController.text.trim().isEmpty) missing.add("Description");
if (selectedTransactionDate.value == null) {
missing.add("Transaction Date");
} else if (selectedTransactionDate.value!.isAfter(DateTime.now())) {
missing.add("Valid Transaction Date");
}
if (double.tryParse(amountController.text.trim()) == null) {
missing.add("Valid Amount");
}
final hasActiveExisting =
existingAttachments.any((e) => e['isActive'] != false);
if (attachments.isEmpty && !hasActiveExisting) {
missing.add("Attachment");
}
return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}.";
}
// --- Snackbar Helper ---
void _errorSnackbar(String msg, [String title = "Error"]) {
showAppSnackbar(title: title, message: msg, type: SnackbarType.error);
}
}

View File

@ -0,0 +1,194 @@
import 'package:get/get.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/model/expense/expense_detail_model.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:flutter/material.dart';
class ExpenseDetailController extends GetxController {
final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null);
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null);
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
final RxList<EmployeeModel> employeeSearchResults = <EmployeeModel>[].obs;
late String _expenseId;
bool _isInitialized = false;
final employeeSearchController = TextEditingController();
final isSearchingEmployees = false.obs;
/// Call this once from the screen (NOT inside build) to initialize
void init(String expenseId) {
if (_isInitialized) return;
_isInitialized = true;
_expenseId = expenseId;
// Use Future.wait to fetch details and employees concurrently
Future.wait([
fetchExpenseDetails(),
fetchAllEmployees(),
]);
}
/// Generic method to handle API calls with loading and error states
Future<T?> _apiCallWrapper<T>(
Future<T?> Function() apiCall, String operationName) async {
isLoading.value = true;
errorMessage.value = ''; // Clear previous errors
try {
logSafe("Initiating $operationName...");
final result = await apiCall();
logSafe("$operationName completed successfully.");
return result;
} catch (e, stack) {
errorMessage.value =
'An unexpected error occurred during $operationName.';
logSafe("Exception in $operationName: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return null;
} finally {
isLoading.value = false;
}
}
/// Fetch expense details by stored ID
Future<void> fetchExpenseDetails() async {
final result = await _apiCallWrapper(
() => ApiService.getExpenseDetailsApi(expenseId: _expenseId),
"fetch expense details");
if (result != null) {
try {
expense.value = ExpenseDetailModel.fromJson(result);
logSafe("Expense details loaded successfully: ${expense.value?.id}");
} catch (e) {
errorMessage.value = 'Failed to parse expense details: $e';
logSafe("Parse error in fetchExpenseDetails: $e",
level: LogLevel.error);
}
} else {
errorMessage.value = 'Failed to fetch expense details from server.';
logSafe("fetchExpenseDetails failed: null response",
level: LogLevel.error);
}
}
// This method seems like a utility and might be better placed in a helper or utility class
// if it's used across multiple controllers. Keeping it here for now as per original code.
List<String> parsePermissionIds(dynamic permissionData) {
if (permissionData == null) return [];
if (permissionData is List) {
return permissionData
.map((e) => e.toString().trim())
.where((e) => e.isNotEmpty)
.toList();
}
if (permissionData is String) {
final clean = permissionData.replaceAll(RegExp(r'[\[\]]'), '');
return clean
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
return [];
}
Future<void> searchEmployees(String query) async {
if (query.trim().isEmpty) return employeeSearchResults.clear();
isSearchingEmployees.value = true;
try {
final data =
await ApiService.searchEmployeesBasic(searchString: query.trim());
employeeSearchResults.assignAll(
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
);
} catch (e) {
logSafe("Error searching employees: $e", level: LogLevel.error);
employeeSearchResults.clear();
} finally {
isSearchingEmployees.value = false;
}
}
/// 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)));
logSafe("All Employees fetched: ${allEmployees.length}",
level: LogLevel.info);
} catch (e) {
errorMessage.value = 'Failed to parse employee data: $e';
logSafe("Parse error in fetchAllEmployees: $e", level: LogLevel.error);
}
} else {
allEmployees.clear();
logSafe("No employees found.", level: LogLevel.warning);
}
// `update()` is typically not needed for RxList directly unless you have specific GetBuilder/Obx usage that requires it
// If you are using Obx widgets, `allEmployees.assignAll` will automatically trigger a rebuild.
}
/// Update expense with reimbursement info and status
Future<bool> updateExpenseStatusWithReimbursement({
required String comment,
required String reimburseTransactionId,
required String reimburseDate,
required String reimburseById,
required String statusId,
double? baseAmount,
double? taxAmount,
double? tdsPercent,
double? netPayable,
}) async {
final success = await _apiCallWrapper(
() => ApiService.updateExpenseStatusApi(
expenseId: _expenseId,
statusId: statusId,
comment: comment,
reimburseTransactionId: reimburseTransactionId,
reimburseDate: reimburseDate,
reimbursedById: reimburseById,
baseAmount: baseAmount,
taxAmount: taxAmount,
tdsPercent: tdsPercent,
netPayable: netPayable,
),
"submit reimbursement",
);
if (success == true) {
await fetchExpenseDetails();
return true;
} else {
errorMessage.value = "Failed to submit reimbursement.";
return false;
}
}
/// Update status for this specific expense
Future<bool> updateExpenseStatus(String statusId, {String? comment}) async {
final success = await _apiCallWrapper(
() => ApiService.updateExpenseStatusApi(
expenseId: _expenseId,
statusId: statusId,
comment: comment,
),
"update expense status",
);
if (success == true) {
await fetchExpenseDetails();
return true;
} else {
errorMessage.value = "Failed to update expense status.";
return false;
}
}
}

View File

@ -0,0 +1,357 @@
import 'dart:convert';
import 'package:get/get.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/model/expense/expense_list_model.dart';
import 'package:on_field_work/model/expense/payment_types_model.dart';
import 'package:on_field_work/model/expense/expense_type_model.dart';
import 'package:on_field_work/model/expense/expense_status_model.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:flutter/material.dart';
class ExpenseController extends GetxController {
final RxList<ExpenseModel> expenses = <ExpenseModel>[].obs;
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
// Master data
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
final RxList<PaymentModeModel> paymentModes = <PaymentModeModel>[].obs;
final RxList<ExpenseStatusModel> expenseStatuses = <ExpenseStatusModel>[].obs;
final RxList<String> globalProjects = <String>[].obs;
final RxMap<String, String> projectsMap = <String, String>{}.obs;
RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
// Persistent Filter States
final RxString selectedProject = ''.obs;
final RxString selectedStatus = ''.obs;
final Rx<DateTime?> startDate = Rx<DateTime?>(null);
final Rx<DateTime?> endDate = Rx<DateTime?>(null);
final RxList<EmployeeModel> selectedPaidByEmployees = <EmployeeModel>[].obs;
final RxList<EmployeeModel> selectedCreatedByEmployees =
<EmployeeModel>[].obs;
final RxString selectedDateType = 'Transaction Date'.obs;
final employeeSearchController = TextEditingController();
final isSearchingEmployees = false.obs;
final employeeSearchResults = <EmployeeModel>[].obs;
final List<String> dateTypes = [
'Transaction Date',
'Created At',
];
int _pageSize = 20;
int _pageNumber = 1;
@override
void onInit() {
super.onInit();
loadInitialMasterData();
fetchAllEmployees();
employeeSearchController.addListener(() {
searchEmployees(employeeSearchController.text);
});
}
bool get isFilterApplied {
return selectedProject.value.isNotEmpty ||
selectedStatus.value.isNotEmpty ||
startDate.value != null ||
endDate.value != null ||
selectedPaidByEmployees.isNotEmpty ||
selectedCreatedByEmployees.isNotEmpty;
}
/// Load master data
Future<void> loadInitialMasterData() async {
await fetchGlobalProjects();
await fetchMasterData();
}
Future<void> deleteExpense(String expenseId) async {
try {
logSafe("Attempting to delete expense: $expenseId");
final success = await ApiService.deleteExpense(expenseId);
if (success) {
expenses.removeWhere((e) => e.id == expenseId);
logSafe("Expense deleted successfully.");
showAppSnackbar(
title: "Deleted",
message: "Expense has been deleted successfully.",
type: SnackbarType.success,
);
} else {
logSafe("Failed to delete expense: $expenseId", level: LogLevel.error);
showAppSnackbar(
title: "Failed",
message: "Failed to delete expense.",
type: SnackbarType.error,
);
}
} catch (e, stack) {
logSafe("Exception in deleteExpense: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
showAppSnackbar(
title: "Error",
message: "Something went wrong while deleting.",
type: SnackbarType.error,
);
}
}
Future<void> searchEmployees(String searchQuery) async {
if (searchQuery.trim().isEmpty) {
employeeSearchResults.clear();
return;
}
isSearchingEmployees.value = true;
try {
final results = await ApiService.searchEmployeesBasic(
searchString: searchQuery.trim(),
);
if (results != null) {
employeeSearchResults.assignAll(
results.map((e) => EmployeeModel.fromJson(e)),
);
} else {
employeeSearchResults.clear();
}
} catch (e) {
logSafe("Error searching employees: $e", level: LogLevel.error);
employeeSearchResults.clear();
} finally {
isSearchingEmployees.value = false;
}
}
/// Fetch expenses using filters
Future<void> fetchExpenses({
List<String>? projectIds,
List<String>? statusIds,
List<String>? createdByIds,
List<String>? paidByIds,
DateTime? startDate,
DateTime? endDate,
int pageSize = 20,
int pageNumber = 1,
}) async {
isLoading.value = true;
errorMessage.value = '';
expenses.clear();
_pageSize = pageSize;
_pageNumber = pageNumber;
final Map<String, dynamic> filterMap = {
"projectIds": projectIds ??
(selectedProject.value.isEmpty
? []
: [projectsMap[selectedProject.value] ?? '']),
"statusIds": statusIds ??
(selectedStatus.value.isEmpty ? [] : [selectedStatus.value]),
"createdByIds":
createdByIds ?? selectedCreatedByEmployees.map((e) => e.id).toList(),
"paidByIds":
paidByIds ?? selectedPaidByEmployees.map((e) => e.id).toList(),
"startDate": (startDate ?? this.startDate.value)?.toIso8601String(),
"endDate": (endDate ?? this.endDate.value)?.toIso8601String(),
"isTransactionDate": selectedDateType.value == 'Transaction Date',
};
try {
logSafe("Fetching expenses with filter: ${jsonEncode(filterMap)}");
final result = await ApiService.getExpenseListApi(
filter: jsonEncode(filterMap),
pageSize: _pageSize,
pageNumber: _pageNumber,
);
if (result != null) {
try {
final expenseResponse = ExpenseResponse.fromJson(result);
// If the backend returns no data, treat it as empty list
if (expenseResponse.data.data.isEmpty) {
expenses.clear();
errorMessage.value = ''; // no error
logSafe("Expense list is empty.");
} else {
expenses.assignAll(expenseResponse.data.data);
logSafe("Expenses loaded: ${expenses.length}");
logSafe(
"Pagination Info: Page ${expenseResponse.data.currentPage} of ${expenseResponse.data.totalPages} | Total: ${expenseResponse.data.totalEntites}");
}
} catch (e) {
errorMessage.value = 'Failed to parse expenses: $e';
logSafe("Parse error in fetchExpenses: $e", level: LogLevel.error);
}
} else {
// Only treat as error if this means a network or server failure
errorMessage.value = 'Unable to connect to the server.';
logSafe("fetchExpenses failed: null response", level: LogLevel.error);
}
} catch (e, stack) {
errorMessage.value = 'An unexpected error occurred.';
logSafe("Exception in fetchExpenses: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
} finally {
isLoading.value = false;
}
}
/// Clear all filters
void clearFilters() {
selectedProject.value = '';
selectedStatus.value = '';
startDate.value = null;
endDate.value = null;
selectedPaidByEmployees.clear();
selectedCreatedByEmployees.clear();
}
/// Fetch master data: Expense Categorys, payment modes, and expense status
Future<void> fetchMasterData() async {
try {
final expenseTypesData = await ApiService.getMasterExpenseTypes();
if (expenseTypesData is List) {
expenseTypes.value =
expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList();
}
final paymentModesData = await ApiService.getMasterPaymentModes();
if (paymentModesData is List) {
paymentModes.value =
paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList();
}
final expenseStatusData = await ApiService.getMasterExpenseStatus();
if (expenseStatusData is List) {
expenseStatuses.value = expenseStatusData
.map((e) => ExpenseStatusModel.fromJson(e))
.toList();
}
} catch (e) {
showAppSnackbar(
title: "Error",
message: "Failed to fetch master data: $e",
type: SnackbarType.error,
);
}
}
/// Fetch global projects
Future<void> fetchGlobalProjects() async {
try {
final response = await ApiService.getGlobalProjects();
if (response != null) {
final names = <String>[];
for (var item in response) {
final name = item['name']?.toString().trim();
final id = item['id']?.toString().trim();
if (name != null && id != null && name.isNotEmpty) {
projectsMap[name] = id;
names.add(name);
}
}
globalProjects.assignAll(names);
logSafe("Fetched ${names.length} global projects");
}
} catch (e) {
logSafe("Failed to fetch global projects: $e", level: LogLevel.error);
}
}
/// Fetch all employees
Future<void> fetchAllEmployees() async {
isLoading.value = true;
try {
final response = await ApiService.getAllEmployees();
if (response != null && response.isNotEmpty) {
allEmployees
.assignAll(response.map((json) => EmployeeModel.fromJson(json)));
logSafe(
"All Employees fetched for Manage Bucket: ${allEmployees.length}",
level: LogLevel.info,
);
} else {
allEmployees.clear();
logSafe("No employees found for Manage Bucket.",
level: LogLevel.warning);
}
} catch (e) {
allEmployees.clear();
logSafe("Error fetching employees in Manage Bucket",
level: LogLevel.error, error: e);
}
isLoading.value = false;
update();
}
Future<void> loadMoreExpenses() async {
if (isLoading.value) return;
_pageNumber += 1;
isLoading.value = true;
final Map<String, dynamic> filterMap = {
"projectIds": selectedProject.value.isEmpty
? []
: [projectsMap[selectedProject.value] ?? ''],
"statusIds": selectedStatus.value.isEmpty ? [] : [selectedStatus.value],
"createdByIds": selectedCreatedByEmployees.map((e) => e.id).toList(),
"paidByIds": selectedPaidByEmployees.map((e) => e.id).toList(),
"startDate": startDate.value?.toIso8601String(),
"endDate": endDate.value?.toIso8601String(),
"isTransactionDate": selectedDateType.value == 'Transaction Date',
};
try {
final result = await ApiService.getExpenseListApi(
filter: jsonEncode(filterMap),
pageSize: _pageSize,
pageNumber: _pageNumber,
);
if (result != null) {
final expenseResponse = ExpenseResponse.fromJson(result);
expenses.addAll(expenseResponse.data.data);
}
} catch (e) {
logSafe("Error in loadMoreExpenses: $e", level: LogLevel.error);
} finally {
isLoading.value = false;
}
}
/// Update expense status
Future<bool> updateExpenseStatus(String expenseId, String statusId) async {
isLoading.value = true;
errorMessage.value = '';
try {
logSafe("Updating status for expense: $expenseId -> $statusId");
final success = await ApiService.updateExpenseStatusApi(
expenseId: expenseId,
statusId: statusId,
);
if (success) {
logSafe("Expense status updated successfully.");
await fetchExpenses();
return true;
} else {
errorMessage.value = "Failed to update expense status.";
return false;
}
} catch (e, stack) {
errorMessage.value = 'An unexpected error occurred.';
logSafe("Exception in updateExpenseStatus: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return false;
} finally {
isLoading.value = false;
}
}
}

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

@ -1,17 +0,0 @@
import 'package:marco/controller/my_controller.dart';
import 'package:marco/helpers/widgets/my_text_utils.dart';
import 'package:marco/model/time_line.dart';
class TimeLineController extends MyController {
List<TimeLineModel> timeline = [];
List<String> dummyTexts = List.generate(12, (index) => MyTextUtils.getDummyText(60));
@override
void onInit() {
TimeLineModel.dummyList.then((value) {
timeline = value.sublist(0, 6);
update();
});
super.onInit();
}
}

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/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,34 +104,53 @@ 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;
} }
List<String> get allowedPermissionIds {
final ids = permissions.map((p) => p.id).toList();
logSafe("[PermissionController] Allowed Permission IDs: $ids",
level: LogLevel.debug);
return ids;
}
bool hasAnyPermission(List<String> ids) {
logSafe("[PermissionController] Checking if any of these are allowed: $ids",
level: LogLevel.debug);
final allowed = allowedPermissionIds;
final result = ids.any((id) => allowed.contains(id));
logSafe("[PermissionController] Permission match result: $result",
level: LogLevel.debug);
return result;
}
@override @override
void onClose() { void onClose() {
_refreshTimer?.cancel(); _refreshTimer?.cancel();

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,180 +0,0 @@
import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/project_model.dart';
import 'package:marco/model/dailyTaskPlaning/daily_task_planing_model.dart';
import 'package:marco/model/employee_model.dart';
class DailyTaskPlaningController extends GetxController {
List<ProjectModel> projects = [];
List<EmployeeModel> employees = [];
List<TaskPlanningDetailsModel> dailyTasks = [];
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs;
MyFormValidator basicValidator = MyFormValidator();
List<Map<String, dynamic>> roles = [];
RxnString selectedRoleId = RxnString();
RxBool isLoading = false.obs;
@override
void onInit() {
super.onInit();
fetchRoles();
_initializeDefaults();
}
void _initializeDefaults() {
fetchProjects();
}
String? formFieldValidator(String? value, {required String fieldType}) {
if (value == null || value.trim().isEmpty) {
return 'This field is required';
}
if (fieldType == "target" && int.tryParse(value.trim()) == null) {
return 'Please enter a valid number';
}
if (fieldType == "description" && value.trim().length < 5) {
return 'Description must be at least 5 characters';
}
return null;
}
void updateSelectedEmployees() {
final selected = employees
.where((e) => uploadingStates[e.id]?.value == true)
.toList();
selectedEmployees.value = selected;
logSafe("Updated selected employees", level: LogLevel.debug, );
}
void onRoleSelected(String? roleId) {
selectedRoleId.value = roleId;
logSafe("Role selected", level: LogLevel.info, );
}
Future<void> fetchRoles() async {
logSafe("Fetching roles...", level: LogLevel.info);
final result = await ApiService.getRoles();
if (result != null) {
roles = List<Map<String, dynamic>>.from(result);
logSafe("Roles fetched successfully", level: LogLevel.info);
update();
} else {
logSafe("Failed to fetch roles", level: LogLevel.error);
}
}
Future<bool> assignDailyTask({
required String workItemId,
required int plannedTask,
required String description,
required List<String> taskTeam,
DateTime? assignmentDate,
}) async {
logSafe("Starting assign task...", level: LogLevel.info);
final response = await ApiService.assignDailyTask(
workItemId: workItemId,
plannedTask: plannedTask,
description: description,
taskTeam: taskTeam,
assignmentDate: assignmentDate,
);
if (response == true) {
logSafe("Task assigned successfully", level: LogLevel.info);
showAppSnackbar(
title: "Success",
message: "Task assigned successfully!",
type: SnackbarType.success,
);
return true;
} else {
logSafe("Failed to assign task", level: LogLevel.error);
showAppSnackbar(
title: "Error",
message: "Failed to assign task.",
type: SnackbarType.error,
);
return false;
}
}
Future<void> fetchProjects() async {
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;
}
}
Future<void> fetchTaskData(String? projectId) async {
if (projectId == null) {
logSafe("Project ID is null", level: LogLevel.warning);
return;
}
isLoading.value = true;
try {
final response = await ApiService.getDailyTasksDetails(projectId);
final data = response?['data'];
if (data != null) {
dailyTasks = [TaskPlanningDetailsModel.fromJson(data)];
logSafe("Daily task Planning Details fetched", level: LogLevel.info, );
} else {
logSafe("Data field is null", level: LogLevel.warning);
}
} catch (e, stack) {
logSafe("Error fetching daily task data", level: LogLevel.error, error: e, stackTrace: stack);
} finally {
isLoading.value = false;
update();
}
}
Future<void> fetchEmployeesByProject(String? projectId) async {
if (projectId == null || projectId.isEmpty) {
logSafe("Project ID is required but was null or empty", level: LogLevel.error);
return;
}
isLoading.value = true;
try {
final response = await ApiService.getAllEmployeesByProject(projectId);
if (response != null && response.isNotEmpty) {
employees = response.map((json) => EmployeeModel.fromJson(json)).toList();
for (var emp in employees) {
uploadingStates[emp.id] = false.obs;
}
logSafe("Employees fetched: ${employees.length} for project $projectId",
level: LogLevel.info, );
} else {
employees = [];
logSafe("No employees found for project $projectId", level: LogLevel.warning, );
}
} catch (e, stack) {
logSafe("Error fetching employees for project $projectId",
level: LogLevel.error, error: e, stackTrace: stack, );
} finally {
isLoading.value = false;
update();
}
}
}

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/dailyTaskPlaning/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

@ -0,0 +1,241 @@
import 'package:flutter/material.dart';
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/project_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 {
List<ProjectModel> projects = [];
String? selectedProjectId;
DateTime? startDateTask;
DateTime? endDateTask;
// Rx fields for DateRangePickerWidget
Rx<DateTime> startDateTaskRx = DateTime.now().obs;
Rx<DateTime> endDateTaskRx = DateTime.now().obs;
List<TaskModel> dailyTasks = [];
final RxSet<String> expandedDates = <String>{}.obs;
void toggleDate(String dateKey) {
if (expandedDates.contains(dateKey)) {
expandedDates.remove(dateKey);
} else {
expandedDates.add(dateKey);
}
}
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 isLoadingMore = false.obs;
Map<String, List<TaskModel>> groupedDailyTasks = {};
// Pagination
int currentPage = 1;
int pageSize = 20;
bool hasMore = true;
FilterData? taskFilterData;
@override
void onInit() {
super.onInit();
_initializeDefaults();
_initializeRxDates();
}
void _initializeDefaults() {
_setDefaultDateRange();
}
void _setDefaultDateRange() {
final today = DateTime.now();
startDateTask = today.subtract(const Duration(days: 7));
endDateTask = today;
logSafe(
"Default date range set: $startDateTask to $endDateTask",
level: LogLevel.info,
);
}
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(
String projectId, {
int pageNumber = 1,
int pageSize = 20,
bool isLoadMore = false,
}) async {
if (!isLoadMore) {
isLoading.value = true;
currentPage = 1;
hasMore = true;
groupedDailyTasks.clear();
dailyTasks.clear();
} else {
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(
projectId,
filter: filter,
pageNumber: pageNumber,
pageSize: pageSize,
);
if (response != null && response.isNotEmpty) {
if (!isLoadMore) {
groupedDailyTasks.clear();
}
for (var task in response) {
final assignmentDateKey =
task.assignmentDate.toIso8601String().split('T')[0];
// 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();
currentPage = pageNumber;
} else {
hasMore = false;
}
isLoading.value = false;
isLoadingMore.value = false;
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(
BuildContext context,
DailyTaskController controller,
) async {
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2022),
lastDate: DateTime.now(),
initialDateRange: DateTimeRange(
start:
startDateTask ?? DateTime.now().subtract(const Duration(days: 7)),
end: endDateTask ?? DateTime.now(),
),
);
if (picked == null) {
logSafe("Date range picker cancelled by user.", level: LogLevel.debug);
return;
}
startDateTask = picked.start;
endDateTask = picked.end;
// update Rx fields as well
startDateTaskRx.value = picked.start;
endDateTaskRx.value = picked.end;
logSafe(
"Date range selected: $startDateTask to $endDateTask",
level: LogLevel.info,
);
final projectId = controller.selectedProjectId;
if (projectId != null && projectId.isNotEmpty) {
await controller.fetchTaskData(projectId);
} else {
logSafe("Project ID is null or empty, skipping fetchTaskData",
level: LogLevel.warning);
}
}
void refreshTasksFromNotification({
required String projectId,
required String taskAllocationId,
}) async {
await fetchTaskData(projectId);
update();
}
}

View File

@ -0,0 +1,367 @@
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/helpers/widgets/my_form_validator.dart';
import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/model/project_model.dart';
import 'package:on_field_work/model/dailyTaskPlanning/daily_task_planning_model.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
class DailyTaskPlanningController extends GetxController {
List<ProjectModel> projects = [];
RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs;
List<EmployeeModel> allEmployeesCache = [];
List<TaskPlanningDetailsModel> dailyTasks = [];
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
MyFormValidator basicValidator = MyFormValidator();
List<Map<String, dynamic>> roles = [];
RxBool isAssigningTask = false.obs;
RxnString selectedRoleId = RxnString();
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
void onInit() {
super.onInit();
fetchRoles();
}
String? formFieldValidator(String? value, {required String fieldType}) {
if (value == null || value.trim().isEmpty) return 'This field is required';
if (fieldType == "target" && int.tryParse(value.trim()) == null) {
return 'Please enter a valid number';
}
if (fieldType == "description" && value.trim().length < 5) {
return 'Description must be at least 5 characters';
}
return null;
}
void updateSelectedEmployees() {
selectedEmployees.value =
employees.where((e) => uploadingStates[e.id]?.value == true).toList();
logSafe("Updated selected employees", level: LogLevel.debug);
}
void onRoleSelected(String? roleId) {
selectedRoleId.value = roleId;
logSafe("Role selected", level: LogLevel.info);
}
Future<void> fetchRoles() async {
logSafe("Fetching roles...", level: LogLevel.info);
final result = await ApiService.getRoles();
if (result != null) {
roles = List<Map<String, dynamic>>.from(result);
logSafe("Roles fetched successfully", level: LogLevel.info);
update();
} else {
logSafe("Failed to fetch roles", level: LogLevel.error);
}
}
Future<bool> assignDailyTask({
required String workItemId,
required int plannedTask,
required String description,
required List<String> taskTeam,
DateTime? assignmentDate,
String? organizationId,
String? serviceId,
}) async {
isAssigningTask.value = true;
logSafe("Starting assign task...", level: LogLevel.info);
final response = await ApiService.assignDailyTask(
workItemId: workItemId,
plannedTask: plannedTask,
description: description,
taskTeam: taskTeam,
assignmentDate: assignmentDate,
organizationId: organizationId,
serviceId: serviceId,
);
isAssigningTask.value = false;
if (response == true) {
logSafe("Task assigned successfully", level: LogLevel.info);
showAppSnackbar(
title: "Success",
message: "Task assigned successfully!",
type: SnackbarType.success,
);
return true;
} else {
logSafe("Failed to assign task", level: LogLevel.error);
showAppSnackbar(
title: "Error",
message: "Failed to assign task.",
type: SnackbarType.error,
);
return false;
}
}
/// Fetch buildings list only (no deep area/workItem calls) for initial load.
Future<void> fetchTaskData(String? projectId, {String? serviceId}) async {
if (projectId == null) return;
isFetchingTasks.value = true;
try {
final infraResponse = await ApiService.getInfraDetails(
projectId,
serviceId: serviceId,
);
final infraData = infraResponse?['data'] as List<dynamic>?;
if (infraData == null || infraData.isEmpty) {
dailyTasks = [];
return;
}
// Filter buildings with 0 planned & completed work
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(
id: buildingJson['id'],
name: buildingJson['buildingName'],
description: buildingJson['description'],
floors: [],
plannedWork: (buildingJson['plannedWork'] as num?)?.toDouble() ?? 0,
completedWork:
(buildingJson['completedWork'] as num?)?.toDouble() ?? 0,
);
return TaskPlanningDetailsModel(
id: building.id,
name: building.name,
projectAddress: "",
contactPerson: "",
startDate: DateTime.now(),
endDate: DateTime.now(),
projectStatusId: "",
buildings: [building],
);
}).toList();
buildingLoadingStates.clear();
buildingsWithDetails.clear();
} catch (e, stack) {
logSafe("Error fetching daily task data",
level: LogLevel.error, error: e, stackTrace: stack);
} finally {
isFetchingTasks.value = false;
update();
}
}
/// Fetch full infra for a single building (floors, workAreas, workItems).
/// Called lazily when user expands a building in the UI.
Future<void> fetchBuildingInfra(
String buildingId, String projectId, String? serviceId) async {
if (buildingId.isEmpty) return;
// mark loading
buildingLoadingStates.putIfAbsent(buildingId, () => true.obs);
buildingLoadingStates[buildingId]!.value = true;
update();
try {
// Re-use getInfraDetails and find the building entry for the requested buildingId
final infraResponse =
await ApiService.getInfraDetails(projectId, serviceId: serviceId);
final infraData = infraResponse?['data'] as List<dynamic>? ?? [];
final buildingJson = infraData.firstWhere(
(b) => b['id'].toString() == buildingId.toString(),
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) {
logSafe("Error fetching tasks for work area ${area.id}",
level: LogLevel.error, error: e, stackTrace: stack);
}
}));
// Merge/replace the building into dailyTasks
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) {
logSafe("Error fetching infra for building $buildingId",
level: LogLevel.error, error: e, stackTrace: stack);
} finally {
buildingLoadingStates.putIfAbsent(buildingId, () => false.obs);
buildingLoadingStates[buildingId]!.value = false;
update();
}
}
Future<void> fetchEmployeesByProjectService({
required String projectId,
String? serviceId,
String? organizationId,
}) async {
isFetchingEmployees.value = true;
try {
final response = await ApiService.getEmployeesByProjectService(
projectId,
serviceId: serviceId ?? '',
organizationId: organizationId ?? '',
);
if (response != null && response.isNotEmpty) {
employees
.assignAll(response.map((json) => EmployeeModel.fromJson(json)));
if (serviceId == null && organizationId == null) {
allEmployeesCache = List.from(employees);
}
final currentEmployeeIds = employees.map((e) => e.id).toSet();
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 {
employees.clear();
uploadingStates.clear();
selectedEmployees.clear();
logSafe(
serviceId != null || organizationId != null
? "Filtered employees empty"
: "No employees found",
level: LogLevel.warning,
);
}
} catch (e, stack) {
logSafe("Error fetching employees",
level: LogLevel.error, error: e, stackTrace: stack);
if (serviceId == null &&
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 {
isFetchingEmployees.value = false;
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_planing/daily_task_planing_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/dailyTaskPlaning/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 DailyTaskPlaningController taskController = Get.put(DailyTaskPlaningController()); 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 {
try {
isPickingImage.value = true; // start loading
logSafe("Opening image picker..."); logSafe("Opening image picker...");
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)); final timestampedFile = await TimestampImageHelper.addTimestamp(
logSafe("Image added from camera: ${pickedFile.path}", ); imageFile: File(pickedFile.path),
);
selectedImages.add(timestampedFile);
logSafe("Image added from camera with timestamp: ${pickedFile.path}");
} }
} else { } else {
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75); final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path))); selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path)));
logSafe("${pickedFiles.length} images added from gallery.", ); logSafe("${pickedFiles.length} images added from gallery.");
}
} catch (e, st) {
logSafe("Error picking images: $e",
level: LogLevel.error, stackTrace: st);
} 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_planing/daily_task_planing_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 DailyTaskPlaningController taskController = Get.put(DailyTaskPlaningController()); 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

@ -0,0 +1,52 @@
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/attendance/organization_per_project_list_model.dart';
class OrganizationController extends GetxController {
/// List of organizations assigned to the selected project
List<Organization> organizations = [];
/// Currently selected organization (reactive)
Rxn<Organization> selectedOrganization = Rxn<Organization>();
/// Loading state for fetching organizations
final isLoadingOrganizations = false.obs;
/// Fetch organizations assigned to a given project
Future<void> fetchOrganizations(String projectId) async {
try {
isLoadingOrganizations.value = true;
final response = await ApiService.getAssignedOrganizations(projectId);
if (response != null && response.data.isNotEmpty) {
organizations = response.data;
logSafe("Organizations fetched: ${organizations.length}");
} else {
organizations = [];
logSafe("No organizations found for project $projectId",
level: LogLevel.warning);
}
} catch (e, stackTrace) {
logSafe("Failed to fetch organizations: $e",
level: LogLevel.error, error: e, stackTrace: stackTrace);
organizations = [];
} finally {
isLoadingOrganizations.value = false;
}
}
/// Select an organization
void selectOrganization(Organization? org) {
selectedOrganization.value = org;
}
/// Clear the selection (set to "All Organizations")
void clearSelection() {
selectedOrganization.value = null;
}
/// Current selection name for UI
String get currentSelection =>
selectedOrganization.value?.name ?? "All Organizations";
}

View File

@ -0,0 +1,43 @@
import 'package:get/get.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/model/tenant/tenant_services_model.dart';
class ServiceController extends GetxController {
List<Service> services = [];
Service? selectedService;
final isLoadingServices = false.obs;
/// Fetch services assigned to a project
Future<void> fetchServices(String projectId) async {
try {
isLoadingServices.value = true;
final response = await ApiService.getAssignedServices(projectId);
if (response != null) {
services = response.data;
logSafe("Services fetched: ${services.length}");
} else {
logSafe("Failed to fetch services for project $projectId",
level: LogLevel.error);
}
} finally {
isLoadingServices.value = false;
update();
}
}
/// Select a service
void selectService(Service? service) {
selectedService = service;
update();
}
/// Clear selection
void clearSelection() {
selectedService = null;
update();
}
/// Current selected name
String get currentSelection => selectedService?.name ?? "All Services";
}

View File

@ -0,0 +1,136 @@
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 TenantSelectionController extends GetxController {
final TenantService _tenantService = TenantService();
// Tenant list
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
void onInit() {
super.onInit();
loadTenants();
}
/// Load tenants and handle auto-selection
Future<void> loadTenants() async {
isLoading.value = true;
isAutoSelecting.value = true; // show splash during auto-selection
try {
final data = await _tenantService.getTenants();
if (data == null || data.isEmpty) {
tenants.clear();
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) {
logSafe("❌ Exception in loadTenants",
level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar(
title: "Error",
message: "Failed to load organizations. Please try again.",
type: SnackbarType.error,
);
} finally {
isLoading.value = false;
isAutoSelecting.value = false; // hide splash
}
}
/// User manually selects a tenant
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 {
isLoading.value = true;
final success = await _tenantService.selectTenant(tenantId);
if (!success) {
showAppSnackbar(
title: "Error",
message: "Unable to select organization. Please try again.",
type: SnackbarType.error,
);
return;
}
// 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(
title: "Error",
message: "An unexpected error occurred while selecting organization.",
type: SnackbarType.error,
);
} finally {
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,26 +0,0 @@
import 'package:marco/controller/my_controller.dart';
import 'package:marco/helpers/widgets/my_text_utils.dart';
import 'package:flutter/material.dart';
import 'package:marco/model/drag_n_drop_model.dart';
class DragNDropController extends MyController {
List<DragNDropModel> dragNDrop = [];
final scrollController = ScrollController();
final gridViewKey = GlobalKey();
List<String> dummyTexts = List.generate(12, (index) => MyTextUtils.getDummyText(60));
@override
void onInit() {
DragNDropModel.dummyList.then((value) {
dragNDrop = value;
update();
});
super.onInit();
}
void onReorder(int oldIndex, int newIndex) {
final item = dragNDrop.removeAt(oldIndex);
dragNDrop.insert(newIndex, item);
update();
}
}

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

@ -1,14 +1,54 @@
class ApiEndpoints { 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://mapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.onfieldwork.com/api";
// Dashboard Screen API Endpoints
static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview";
// Attendance Screen API Endpoints 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
static const String getDashboardAttendanceOverview =
"/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 getDashboardTasks = "/dashboard/tasks";
static const String getDashboardTeams = "/dashboard/teams";
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
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 getEmployeesByProject = "/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";
@ -16,15 +56,17 @@ 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 getRoles = "/roles/jobrole"; static const String getRoles = "/roles/jobrole";
static const String createEmployee = "/employee/manage-mobile"; static const String createEmployee = "/employee/app/manage";
static const String getEmployeeInfo = "/employee/profile/get"; static const String getEmployeeInfo = "/employee/profile/get";
static const String assignEmployee = "/employee/profile/get"; static const String assignEmployee = "/employee/profile/get";
static const String getAssignedProjects = "/project/assigned-projects"; static const String getAssignedProjects = "/project/assigned-projects";
static const String assignProjects = "/project/assign-projects"; static const String assignProjects = "/project/assign-projects";
// Daily Task Screen API Endpoints // Daily Task Module API Endpoints
static const String getDailyTask = "/task/list"; static const String getDailyTask = "/task/list";
static const String reportTask = "/task/report"; static const String reportTask = "/task/report";
static const String commentTask = "/task/comment"; static const String commentTask = "/task/comment";
@ -34,19 +76,95 @@ 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 Screen API Endpoints ////// Directory Module API Endpoints ///////
static const String getDirectoryContacts = "/directory"; static const String getDirectoryContacts = "/directory";
static const String getDirectoryBucketList = "/directory/buckets"; static const String getDirectoryBucketList = "/directory/buckets";
static const String getDirectoryContactDetail = "/directory/notes"; static const String getDirectoryContactDetail = "/directory/notes";
static const String getDirectoryContactCategory = "/master/contact-categories"; static const String getDirectoryContactCategory =
"/master/contact-categories";
static const String getDirectoryContactTags = "/master/contact-tags"; static const String getDirectoryContactTags = "/master/contact-tags";
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";
static const String updateBucket = "/directory/bucket"; static const String updateBucket = "/directory/bucket";
static const String assignBucket = "/directory/assign-bucket"; static const String assignBucket = "/directory/assign-bucket";
////// Expense Module API Endpoints
static const String getExpenseCategories = "/expense/categories";
static const String getExpenseList = "/expense/list";
static const String getExpenseDetails = "/expense/details";
static const String createExpense = "/expense/create";
static const String editExpense = "/Expense/edit";
static const String getMasterPaymentModes = "/master/payment-modes";
static const String getMasterExpenseStatus = "/master/expenses-status";
static const String getMasterExpenseCategory = "/master/expenses-categories";
static const String updateExpenseStatus = "/expense/action";
static const String deleteExpense = "/expense/delete";
////// Dynamic Menu Module API Endpoints
static const String getDynamicMenu = "/appmenu/get/menu-mobile";
///// Document Module API Endpoints
static const String getMasterDocumentCategories =
"/master/document-category/list";
static const String getMasterDocumentTags = "/document/get/tags";
static const String getDocumentList = "/document/list";
static const String getDocumentDetails = "/document/get/details";
static const String uploadDocument = "/document/upload";
static const String deleteDocument = "/document/delete";
static const String getDocumentFilter = "/document/get/filter";
static const String getDocumentTypesByCategory = "/master/document-type/list";
static const String getDocumentVersion = "/document/get/version";
static const String getDocumentVersions = "/document/list/versions";
static const String editDocument = "/document/edit";
static const String verifyDocument = "/document/verify";
/// Logs Module API Endpoints
static const String uploadLogs = "/log";
static const String getAssignedOrganizations =
"/project/get/assigned/organization";
static const getAllOrganizations = "/organization/list";
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,67 +1,30 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/theme/theme_customizer.dart';
import 'package:marco/helpers/theme/app_theme.dart';
import 'package:url_strategy/url_strategy.dart'; import 'package:url_strategy/url_strategy.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/helpers/services/firebase/firebase_messaging_service.dart';
import 'package:on_field_work/helpers/services/device_info_service.dart';
import 'package:on_field_work/helpers/theme/theme_customizer.dart';
import 'package:on_field_work/helpers/theme/app_theme.dart';
Future<void> initializeApp() async { Future<void> initializeApp() async {
try { try {
logSafe("💡 Starting app initialization..."); logSafe("💡 Starting app initialization...");
setPathUrlStrategy(); await Future.wait([
logSafe("💡 URL strategy set."); _setupUI(),
_setupFirebase(),
_setupLocalStorage(),
]);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( await _setupDeviceInfo();
statusBarColor: Color.fromARGB(255, 255, 0, 0), await _handleAuthTokens();
statusBarIconBrightness: Brightness.light, await _setupTheme();
)); await _setupFirebaseMessaging();
logSafe("💡 System UI overlay style set.");
await LocalStorage.init(); _finalizeAppStyle();
logSafe("💡 Local storage initialized.");
// If a refresh token is found, try to refresh the JWT token
final refreshToken = await LocalStorage.getRefreshToken();
if (refreshToken != null && refreshToken.isNotEmpty) {
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
final success = await AuthService.refreshToken();
if (!success) {
logSafe("⚠️ Refresh token invalid or expired. Skipping controller injection.");
// Optionally, clear tokens and force logout here if needed
}
} else {
logSafe("❌ No refresh token found. Skipping refresh.");
}
await ThemeCustomizer.init();
logSafe("💡 Theme customizer initialized.");
final token = LocalStorage.getString('jwt_token');
if (token != null && token.isNotEmpty) {
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
logSafe("💡 PermissionController injected.");
}
if (!Get.isRegistered<ProjectController>()) {
Get.put(ProjectController(), permanent: true);
logSafe("💡 ProjectController injected as permanent.");
}
// Load data into controllers if required
await Get.find<PermissionController>().loadData(token);
await Get.find<ProjectController>().fetchProjects();
} else {
logSafe("⚠️ No valid JWT token found. Skipping controller initialization.");
}
AppStyle.init();
logSafe("💡 AppStyle initialized.");
logSafe("✅ App initialization completed successfully."); logSafe("✅ App initialization completed successfully.");
} catch (e, stacktrace) { } catch (e, stacktrace) {
@ -74,3 +37,57 @@ Future<void> initializeApp() async {
rethrow; rethrow;
} }
} }
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 {
setPathUrlStrategy();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
logSafe("💡 UI setup completed with default system behavior.");
}
Future<void> _setupFirebase() async {
await Firebase.initializeApp();
logSafe("💡 Firebase initialized.");
}
Future<void> _setupLocalStorage() async {
if (!LocalStorage.isInitialized) {
await LocalStorage.init();
logSafe("💡 Local storage initialized.");
} else {
logSafe(" Local storage already initialized, skipping.");
}
}
Future<void> _setupDeviceInfo() async {
final deviceInfoService = DeviceInfoService();
await deviceInfoService.init();
logSafe("📱 Device Info: ${deviceInfoService.deviceData}");
}
Future<void> _setupTheme() async {
await ThemeCustomizer.init();
logSafe("💡 Theme customizer initialized.");
}
Future<void> _setupFirebaseMessaging() async {
await FirebaseNotificationService().initialize();
logSafe("💡 Firebase Messaging initialized.");
}
void _finalizeAppStyle() {
AppStyle.init();
logSafe("💡 AppStyle initialized.");
}

View File

@ -1,21 +1,42 @@
import 'dart:io'; 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:permission_handler/permission_handler.dart'; import 'package:path_provider/path_provider.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
/// Global logger instance /// Global logger instance
late final Logger appLogger; Logger? _appLogger;
late final FileLogOutput _fileLogOutput;
/// Log file output handler /// Store logs temporarily for API posting
late final FileLogOutput fileLogOutput; final List<Map<String, dynamic>> _logBuffer = [];
/// Initialize logging (call once in `main()`) /// Lock flag to prevent concurrent posting
bool _isPosting = false;
/// Flag to allow API posting only after login
bool _canPostLogs = false;
/// Maximum number of logs before triggering API post
const int _maxLogsBeforePost = 100;
/// Maximum logs in memory buffer
const int _maxBufferSize = 500;
/// Enum logger level mapping
const _levelMap = {
LogLevel.debug: Level.debug,
LogLevel.info: Level.info,
LogLevel.warning: Level.warning,
LogLevel.error: Level.error,
LogLevel.verbose: Level.verbose,
};
/// Initialize logging
Future<void> initLogging() async { Future<void> initLogging() async {
await requestStoragePermission(); _fileLogOutput = FileLogOutput();
fileLogOutput = FileLogOutput(); _appLogger = Logger(
appLogger = Logger(
printer: PrettyPrinter( printer: PrettyPrinter(
methodCount: 0, methodCount: 0,
printTime: true, printTime: true,
@ -23,19 +44,17 @@ Future<void> initLogging() async {
printEmojis: true, printEmojis: true,
), ),
output: MultiOutput([ output: MultiOutput([
ConsoleOutput(), // Console will use the top-level PrettyPrinter ConsoleOutput(),
fileLogOutput, // File will still use the SimpleFileLogPrinter _fileLogOutput,
]), ]),
level: Level.debug, level: Level.debug,
); );
} }
/// Request storage permission (for Android 11+) /// Enable API posting after login
Future<void> requestStoragePermission() async { void enableRemoteLogging() {
final status = await Permission.manageExternalStorage.status; _canPostLogs = true;
if (!status.isGranted) { _postBufferedLogs(); // flush logs if any
await Permission.manageExternalStorage.request();
}
} }
/// Safe logger wrapper /// Safe logger wrapper
@ -46,35 +65,68 @@ void logSafe(
StackTrace? stackTrace, StackTrace? stackTrace,
bool sensitive = false, bool sensitive = false,
}) { }) {
if (sensitive) return; if (sensitive || _appLogger == null) return;
switch (level) { final loggerLevel = _levelMap[level] ?? Level.info;
case LogLevel.debug: _appLogger!.log(loggerLevel, message, error: error, stackTrace: stackTrace);
appLogger.d(message, error: error, stackTrace: stackTrace);
break; // Buffer logs for API posting
case LogLevel.warning: _logBuffer.add({
appLogger.w(message, error: error, stackTrace: stackTrace); "logLevel": level.name,
break; "message": message,
case LogLevel.error: "timeStamp": DateTime.now().toUtc().toIso8601String(),
appLogger.e(message, error: error, stackTrace: stackTrace); "ipAddress": "this is test IP", // TODO: real IP
break; "userAgent": "FlutterApp/1.0", // TODO: device_info_plus
case LogLevel.verbose: "details": error?.toString() ?? stackTrace?.toString(),
appLogger.v(message, error: error, stackTrace: stackTrace); });
break;
default: if (_logBuffer.length >= _maxLogsBeforePost) {
appLogger.i(message, error: error, stackTrace: stackTrace); _postBufferedLogs();
} }
} }
/// Custom log output that writes to a local `.txt` file /// Post buffered logs to API
Future<void> _postBufferedLogs() async {
if (!_canPostLogs) return; // 🚫 skip if not logged in
if (_isPosting || _logBuffer.isEmpty) return;
_isPosting = true;
final logsToSend = List<Map<String, dynamic>>.from(_logBuffer);
_logBuffer.clear();
try {
final success = await ApiService.postLogsApi(logsToSend);
if (!success) {
_reinsertLogs(logsToSend, reason: "API call returned false");
}
} catch (e) {
_reinsertLogs(logsToSend, reason: "API exception: $e");
} finally {
_isPosting = false;
}
}
/// Reinsert logs into buffer if posting fails
void _reinsertLogs(List<Map<String, dynamic>> logs, {required String reason}) {
_appLogger?.w("Failed to post logs, re-queuing. Reason: $reason");
if (_logBuffer.length + logs.length > _maxBufferSize) {
_appLogger?.e("Buffer full. Dropping ${logs.length} logs to prevent crash.");
return;
}
_logBuffer.insertAll(0, logs);
}
/// File-based log output (safe storage)
class FileLogOutput extends LogOutput { class FileLogOutput extends LogOutput {
File? _logFile; File? _logFile;
/// Initialize log file in Downloads/marco_logs/log_YYYY-MM-DD.txt
Future<void> _init() async { Future<void> _init() async {
if (_logFile != null) return; if (_logFile != null) return;
final directory = Directory('/storage/emulated/0/Download/marco_logs'); final baseDir = await getExternalStorageDirectory();
final directory = Directory('${baseDir!.path}/marco_logs');
if (!await directory.exists()) { if (!await directory.exists()) {
await directory.create(recursive: true); await directory.create(recursive: true);
} }
@ -93,7 +145,6 @@ class FileLogOutput extends LogOutput {
@override @override
void output(OutputEvent event) async { void output(OutputEvent event) async {
await _init(); await _init();
if (event.lines.isEmpty) return; if (event.lines.isEmpty) return;
final logMessage = event.lines.join('\n') + '\n'; final logMessage = event.lines.join('\n') + '\n';
@ -119,7 +170,6 @@ class FileLogOutput extends LogOutput {
return _logFile!.readAsString(); return _logFile!.readAsString();
} }
/// Delete logs older than 3 days
Future<void> _cleanOldLogs(Directory directory) async { Future<void> _cleanOldLogs(Directory directory) async {
final files = directory.listSync(); final files = directory.listSync();
final now = DateTime.now(); final now = DateTime.now();
@ -135,22 +185,5 @@ class FileLogOutput extends LogOutput {
} }
} }
/// A simple, readable log printer for file output /// Custom log levels
class SimpleFileLogPrinter extends LogPrinter {
@override
List<String> log(LogEvent event) {
final message = event.message.toString();
if (message.contains('[SENSITIVE]')) return [];
final timestamp = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
final level = event.level.name.toUpperCase();
final error = event.error != null ? ' | ERROR: ${event.error}' : '';
final stack =
event.stackTrace != null ? '\nSTACKTRACE:\n${event.stackTrace}' : '';
return ['[$timestamp] [$level] $message$error$stack'];
}
}
/// Optional log level enum for better type safety
enum LogLevel { debug, info, warning, error, verbose } enum LogLevel { debug, info, warning, error, verbose }

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;
@ -15,277 +11,282 @@ class AuthService {
}; };
static bool isLoggedIn = false; static bool isLoggedIn = false;
/* -------------------------------------------------------------------------- */
/// Login with email and password /* Logout API */
static Future<Map<String, String>?> loginUser(Map<String, dynamic> data) async { /* -------------------------------------------------------------------------- */
static Future<bool> logoutApi(String refreshToken, String fcmToken) async {
try { try {
logSafe("Attempting login..."); final body = {
final response = await http.post( "refreshToken": refreshToken,
Uri.parse("$_baseUrl/auth/login-mobile"), "fcmToken": fcmToken,
headers: _headers, };
body: jsonEncode(data),
);
final responseData = jsonDecode(response.body); final response = await _post("/auth/logout", body);
if (response.statusCode == 200 && responseData['data'] != null) {
if (response != null && response['statusCode'] == 200) {
logSafe("✅ Logout API successful");
return true;
}
logSafe("⚠️ Logout API failed: ${response?['message']}",
level: LogLevel.warning);
return false;
} catch (e, st) {
_handleError("Logout API error", e, st);
return false;
}
}
/* -------------------------------------------------------------------------- */
/* Public Methods */
/* -------------------------------------------------------------------------- */
static Future<bool> registerDeviceToken(String fcmToken) async {
final token = await LocalStorage.getJwtToken();
if (token == null || token.isEmpty) {
logSafe("❌ Cannot register device token: missing JWT token",
level: LogLevel.warning);
return false;
}
final body = {"fcmToken": fcmToken};
final headers = {
..._headers,
'Authorization': 'Bearer $token',
};
final endpoint = "$_baseUrl/auth/set/device-token";
// 🔹 Log request details
logSafe("📡 Device Token API Request");
logSafe("➡️ Endpoint: $endpoint");
logSafe("➡️ Headers: ${jsonEncode(headers)}");
logSafe("➡️ Payload: ${jsonEncode(body)}");
final data = await _post("/auth/set/device-token", body, authToken: token);
if (data != null && data['success'] == true) {
logSafe("✅ Device token registered successfully.");
return true;
}
logSafe("⚠️ Failed to register device token: ${data?['message']}",
level: LogLevel.warning);
return false;
}
static Future<Map<String, String>?> loginUser(
Map<String, dynamic> data) async {
logSafe("Attempting login...");
logSafe("Login payload (raw): $data");
logSafe("Login payload (JSON): ${jsonEncode(data)}");
final responseData = await _post("/auth/app/login", data);
if (responseData == null)
return {"error": "Network error. Please check your connection."};
if (responseData['data'] != null) {
await _handleLoginSuccess(responseData['data']); await _handleLoginSuccess(responseData['data']);
return null; return null;
} else if (response.statusCode == 401) { }
logSafe("Invalid login credentials.", level: LogLevel.warning); if (responseData['statusCode'] == 401) {
return {"password": "Invalid email or password"}; return {"password": "Invalid email or password"};
} else { }
logSafe("Login error: ${responseData['message']}", level: LogLevel.warning);
return {"error": responseData['message'] ?? "Unexpected error occurred"}; return {"error": responseData['message'] ?? "Unexpected error occurred"};
} }
} catch (e, stacktrace) {
logSafe("Login exception", level: LogLevel.error, error: e, stackTrace: stacktrace);
return {"error": "Network error. Please check your connection."};
}
}
/// Refresh JWT token
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 == null || refreshToken == null || accessToken.isEmpty || refreshToken.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);
return false; return false;
} }
final requestBody = { final body = {"token": accessToken, "refreshToken": refreshToken};
"token": accessToken, final data = await _post("/auth/refresh-token", body);
"refreshToken": refreshToken, if (data != null && data['success'] == true) {
};
try {
logSafe("Refreshing token...");
final response = await http.post(
Uri.parse("$_baseUrl/auth/refresh-token"),
headers: _headers,
body: jsonEncode(requestBody),
);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) {
await LocalStorage.setJwtToken(data['data']['token']); await LocalStorage.setJwtToken(data['data']['token']);
await LocalStorage.setRefreshToken(data['data']['refreshToken']); await LocalStorage.setRefreshToken(data['data']['refreshToken']);
await LocalStorage.setLoggedInUser(true); await LocalStorage.setLoggedInUser(true);
logSafe("Token refreshed successfully."); logSafe("Token refreshed successfully.");
// 🔹 Retry FCM token registration after token refresh
final newFcmToken = LocalStorage.getFcmToken();
if (newFcmToken?.isNotEmpty ?? false) {
final success = await registerDeviceToken(newFcmToken!);
logSafe(
success
? "✅ FCM token re-registered after JWT refresh."
: "⚠️ Failed to register FCM token after JWT refresh.",
level: success ? LogLevel.info : LogLevel.warning);
}
return true; return true;
} else { }
logSafe("Refresh token failed: ${data['message']}", level: LogLevel.warning); logSafe("Refresh token failed: ${data?['message']}",
level: LogLevel.warning);
return false; return false;
} }
} catch (e, stacktrace) {
logSafe("Token refresh exception", level: LogLevel.error, error: e, stackTrace: stacktrace);
return false;
}
}
/// Forgot password static Future<Map<String, String>?> forgotPassword(String email) =>
static Future<Map<String, String>?> forgotPassword(String email) async { _wrapErrorHandling(() => _post("/auth/forgot-password", {"email": email}),
try { successCondition: (data) => data['success'] == true,
logSafe("Forgot password requested."); defaultError: "Failed to send reset link.");
final response = await http.post(
Uri.parse("$_baseUrl/auth/forgot-password"),
headers: _headers,
body: jsonEncode({"email": email}),
);
final data = jsonDecode(response.body); static Future<Map<String, String>?> requestDemo(
if (response.statusCode == 200 && data['success'] == true) return null; Map<String, dynamic> demoData) =>
return {"error": data['message'] ?? "Failed to send reset link."}; _wrapErrorHandling(() => _post("/market/inquiry", demoData),
} catch (e, stacktrace) { successCondition: (data) => data['success'] == true,
logSafe("Forgot password error", level: LogLevel.error, error: e, stackTrace: stacktrace); defaultError: "Failed to submit demo request.");
return {"error": "Network error. Please check your connection."};
}
}
/// Request demo
static Future<Map<String, String>?> requestDemo(Map<String, dynamic> demoData) async {
try {
logSafe("Submitting demo request...");
final response = await http.post(
Uri.parse("$_baseUrl/market/inquiry"),
headers: _headers,
body: jsonEncode(demoData),
);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) return null;
return {"error": data['message'] ?? "Failed to submit demo request."};
} catch (e, stacktrace) {
logSafe("Request demo error", level: LogLevel.error, error: e, stackTrace: stacktrace);
return {"error": "Network error. Please check your connection."};
}
}
/// Get list of industries
static Future<List<Map<String, dynamic>>?> getIndustries() async { static Future<List<Map<String, dynamic>>?> getIndustries() async {
try { final data = await _get("/market/industries");
logSafe("Fetching industries list..."); if (data != null && data['success'] == true) {
final response = await http.get(
Uri.parse("$_baseUrl/market/industries"),
headers: _headers,
);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) {
return List<Map<String, dynamic>>.from(data['data']); return List<Map<String, dynamic>>.from(data['data']);
} }
return null; return null;
} catch (e, stacktrace) {
logSafe("Get industries error", level: LogLevel.error, error: e, stackTrace: stacktrace);
return null;
}
} }
/// Generate MPIN
static Future<Map<String, String>?> generateMpin({ static Future<Map<String, String>?> generateMpin({
required String employeeId, required String employeeId,
required String mpin, required String mpin,
}) async { }) =>
final token = await LocalStorage.getJwtToken(); _wrapErrorHandling(
() async {
try { final token = LocalStorage.getJwtToken();
logSafe("Generating MPIN..."); return _post(
final response = await http.post( "/auth/generate-mpin",
Uri.parse("$_baseUrl/auth/generate-mpin"), {"employeeId": employeeId, "mpin": mpin},
headers: { authToken: token,
..._headers, );
if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token',
}, },
body: jsonEncode({"employeeId": employeeId, "mpin": mpin}), successCondition: (data) => data['success'] == true,
defaultError: "Failed to generate MPIN.",
); );
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) return null;
return {"error": data['message'] ?? "Failed to generate MPIN."};
} catch (e, stacktrace) {
logSafe("Generate MPIN error", level: LogLevel.error, error: e, stackTrace: stacktrace);
return {"error": "Network error. Please check your connection."};
}
}
/// Verify MPIN
static Future<Map<String, String>?> verifyMpin({ static Future<Map<String, String>?> verifyMpin({
required String mpin, required String mpin,
required String mpinToken, required String mpinToken,
}) async { required String fcmToken,
}) =>
_wrapErrorHandling(
() async {
final employeeInfo = LocalStorage.getEmployeeInfo(); final employeeInfo = LocalStorage.getEmployeeInfo();
if (employeeInfo == null) return {"error": "Employee info not found."}; if (employeeInfo == null) return null;
final token = await LocalStorage.getJwtToken(); final token = await LocalStorage.getJwtToken();
return _post(
try { "/auth/login-mpin",
logSafe("Verifying MPIN..."); {
final response = await http.post(
Uri.parse("$_baseUrl/auth/login-mpin"),
headers: {
..._headers,
if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token',
},
body: jsonEncode({
"employeeId": employeeInfo.id, "employeeId": employeeInfo.id,
"mpin": mpin, "mpin": mpin,
"mpinToken": mpinToken, "mpinToken": mpinToken,
}), "fcmToken": fcmToken,
},
authToken: token,
);
},
successCondition: (data) => data['success'] == true,
defaultError: "MPIN verification failed.",
); );
final data = jsonDecode(response.body); static Future<Map<String, String>?> generateOtp(String email) =>
if (response.statusCode == 200 && data['success'] == true) return null; _wrapErrorHandling(() => _post("/auth/send-otp", {"email": email}),
return {"error": data['message'] ?? "MPIN verification failed."}; successCondition: (data) => data['success'] == true,
} catch (e, stacktrace) { defaultError: "Failed to generate OTP.");
logSafe("Verify MPIN error", level: LogLevel.error, error: e, stackTrace: stacktrace);
return {"error": "Network error. Please check your connection."};
}
}
/// Generate OTP
static Future<Map<String, String>?> generateOtp(String email) async {
try {
logSafe("Generating OTP for email...");
final response = await http.post(
Uri.parse("$_baseUrl/auth/send-otp"),
headers: _headers,
body: jsonEncode({"email": email}),
);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) return null;
return {"error": data['message'] ?? "Failed to generate OTP."};
} catch (e, stacktrace) {
logSafe("Generate OTP error", level: LogLevel.error, error: e, stackTrace: stacktrace);
return {"error": "Network error. Please check your connection."};
}
}
/// Verify OTP and login
static Future<Map<String, String>?> verifyOtp({ static Future<Map<String, String>?> verifyOtp({
required String email, required String email,
required String otp, required String otp,
}) async { }) async {
try { final data = await _post("/auth/login-otp", {"email": email, "otp": otp});
logSafe("Verifying OTP..."); if (data != null && data['data'] != null) {
final response = await http.post(
Uri.parse("$_baseUrl/auth/login-otp"),
headers: _headers,
body: jsonEncode({"email": email, "otp": otp}),
);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['data'] != null) {
await _handleLoginSuccess(data['data']); await _handleLoginSuccess(data['data']);
return null; return null;
} }
return {"error": data['message'] ?? "OTP verification failed."}; return {"error": data?['message'] ?? "OTP verification failed."};
} catch (e, stacktrace) { }
logSafe("Verify OTP error", level: LogLevel.error, error: e, stackTrace: stacktrace);
return {"error": "Network error. Please check your connection."}; /* -------------------------------------------------------------------------- */
/* Private Utilities */
/* -------------------------------------------------------------------------- */
static Future<Map<String, dynamic>?> _post(
String path,
Map<String, dynamic> body, {
String? authToken,
}) async {
try {
final headers = {
..._headers,
if (authToken?.isNotEmpty ?? false)
'Authorization': 'Bearer $authToken',
};
final response = await http.post(Uri.parse("$_baseUrl$path"),
headers: headers, body: jsonEncode(body));
return {
...jsonDecode(response.body),
"statusCode": response.statusCode,
};
} catch (e, st) {
_handleError("$path POST error", e, st);
return null;
} }
} }
/// Handle login success flow static Future<Map<String, dynamic>?> _get(
String path, {
String? authToken,
}) async {
try {
final headers = {
..._headers,
if (authToken?.isNotEmpty ?? false)
'Authorization': 'Bearer $authToken',
};
final response =
await http.get(Uri.parse("$_baseUrl$path"), headers: headers);
return {
...jsonDecode(response.body),
"statusCode": response.statusCode,
};
} catch (e, st) {
_handleError("$path GET error", e, st);
return null;
}
}
static Future<Map<String, String>?> _wrapErrorHandling(
Future<Map<String, dynamic>?> Function() request, {
required bool Function(Map<String, dynamic> data) successCondition,
required String defaultError,
}) async {
final data = await request();
if (data != null && successCondition(data)) return null;
return {"error": data?['message'] ?? defaultError};
}
static void _handleError(String message, Object error, StackTrace st) {
logSafe(message, level: LogLevel.error, error: error, stackTrace: st);
}
static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async { static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async {
logSafe("Processing login success..."); logSafe("Processing login success...");
final jwtToken = data['token']; await LocalStorage.setJwtToken(data['token']);
final refreshToken = data['refreshToken'];
final mpinToken = data['mpinToken'];
// Save tokens
await LocalStorage.setJwtToken(jwtToken);
await LocalStorage.setLoggedInUser(true); await LocalStorage.setLoggedInUser(true);
if (refreshToken != null) { if (data['refreshToken'] != null) {
await LocalStorage.setRefreshToken(refreshToken); await LocalStorage.setRefreshToken(data['refreshToken']);
} }
if (mpinToken != null && mpinToken.isNotEmpty) { if (data['mpinToken']?.isNotEmpty ?? false) {
await LocalStorage.setMpinToken(mpinToken); await LocalStorage.setMpinToken(data['mpinToken']);
await LocalStorage.setIsMpin(true); await LocalStorage.setIsMpin(true);
} else { } else {
await LocalStorage.setIsMpin(false); await LocalStorage.setIsMpin(false);
await LocalStorage.removeMpinToken(); await LocalStorage.removeMpinToken();
} }
// Inject controllers if not already registered
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.");
}
// Load data into controllers
await Get.find<PermissionController>().loadData(jwtToken);
await Get.find<ProjectController>().fetchProjects();
isLoggedIn = true; isLoggedIn = true;
logSafe("✅ Login flow completed and controllers initialized."); logSafe("✅ Login flow completed and controllers initialized.");
} }
} }

View File

@ -0,0 +1,51 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
class DeviceInfoService {
static final DeviceInfoService _instance = DeviceInfoService._internal();
factory DeviceInfoService() => _instance;
DeviceInfoService._internal();
final DeviceInfoPlugin _deviceInfoPlugin = DeviceInfoPlugin();
Map<String, dynamic> _deviceData = {};
/// Initialize device info (call this in main before runApp)
Future<void> init() async {
try {
if (Platform.isAndroid) {
final androidInfo = await _deviceInfoPlugin.androidInfo;
_deviceData = {
'platform': 'Android',
'manufacturer': androidInfo.manufacturer,
'model': androidInfo.model,
'version': androidInfo.version.release,
'sdkInt': androidInfo.version.sdkInt,
'brand': androidInfo.brand,
'device': androidInfo.device,
'androidId': androidInfo.id,
};
} else if (Platform.isIOS) {
final iosInfo = await _deviceInfoPlugin.iosInfo;
_deviceData = {
'platform': 'iOS',
'name': iosInfo.name,
'systemName': iosInfo.systemName,
'systemVersion': iosInfo.systemVersion,
'model': iosInfo.model,
'localizedModel': iosInfo.localizedModel,
'identifierForVendor': iosInfo.identifierForVendor,
};
} else {
_deviceData = {'platform': 'Unknown'};
}
} catch (e) {
_deviceData = {'error': 'Failed to get device info: $e'};
}
}
/// Get the whole device info map
Map<String, dynamic> get deviceData => _deviceData;
/// Get a specific property from device info
String? getProperty(String key) => _deviceData[key]?.toString();
}

View File

@ -0,0 +1,141 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:logger/logger.dart';
import 'package:on_field_work/helpers/services/local_notification_service.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/helpers/services/notification_action_handler.dart';
/// Firebase Notification Service
class FirebaseNotificationService {
final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
final Logger _logger = Logger();
/// Initialize FCM (Firebase.initializeApp() should be called once globally)
Future<void> initialize() async {
_logger.i('✅ FirebaseMessaging initializing...');
await _requestNotificationPermission();
_registerMessageListeners();
_registerTokenRefreshListener();
// Fetch token on app start (and register with server if JWT available)
await getFcmToken(registerOnServer: true);
}
/// Request notification permission
Future<void> _requestNotificationPermission() async {
final settings = await _firebaseMessaging.requestPermission();
_logger.i('📩 Permission Status: ${settings.authorizationStatus}');
}
/// Foreground, background, and tap listeners
void _registerMessageListeners() {
FirebaseMessaging.onMessage.listen((message) {
_logger.i('📩 Foreground Notification');
_logNotificationDetails(message);
// Handle custom actions
NotificationActionHandler.handle(message.data);
// Show local notification
if (message.notification != null) {
LocalNotificationService.showNotification(
title: message.notification!.title ?? "No title",
body: message.notification!.body ?? "No body",
);
}
});
FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
// Background messages
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
}
/// Token refresh handler
void _registerTokenRefreshListener() {
_firebaseMessaging.onTokenRefresh.listen((newToken) async {
_logger.i('🔄 Token refreshed: $newToken');
if (newToken.isEmpty) return;
await LocalStorage.setFcmToken(newToken);
final jwt = await LocalStorage.getJwtToken();
if (jwt?.isNotEmpty ?? false) {
final success = await AuthService.registerDeviceToken(newToken);
_logger.i(success
? '✅ Device token updated on server after refresh.'
: '⚠️ Failed to update device token on server.');
} else {
_logger.w('⚠️ JWT not available — will retry after login.');
}
});
}
/// Get current token (optionally sync to server if logged in)
Future<String?> getFcmToken({bool registerOnServer = false}) async {
try {
final token = await _firebaseMessaging.getToken();
_logger.i('🔑 FCM token: $token');
if (token?.isNotEmpty ?? false) {
await LocalStorage.setFcmToken(token!);
if (registerOnServer) {
final jwt = await LocalStorage.getJwtToken();
if (jwt?.isNotEmpty ?? false) {
final success = await AuthService.registerDeviceToken(token);
_logger.i(success
? '✅ Device token registered on server.'
: '⚠️ Failed to register device token on server.');
} else {
_logger.w('⚠️ JWT not available — skipping server registration.');
}
}
}
return token;
} catch (e, s) {
_logger.e('❌ Failed to get FCM token', error: e, stackTrace: s);
return null;
}
}
/// Re-register token with server (useful after login)
Future<void> registerTokenAfterLogin() async {
final token = await LocalStorage.getFcmToken();
if (token?.isNotEmpty ?? false) {
final success = await AuthService.registerDeviceToken(token!);
_logger.i(success
? "✅ FCM token registered after login."
: "⚠️ Failed to register FCM token after login.");
}
}
/// Handle tap on notification
void _handleNotificationTap(RemoteMessage message) {
_logger.i('📌 Notification tapped: ${message.data}');
NotificationActionHandler.handle(message.data);
}
/// Log notification details
void _logNotificationDetails(RemoteMessage message) {
_logger
..i('🆔 ID: ${message.messageId}')
..i('📜 Title: ${message.notification?.title}')
..i('📜 Body: ${message.notification?.body}')
..i('📦 Data: ${message.data}');
}
}
/// 🔹 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 {
final logger = Logger();
logger
..i('⚡ Handling background notification...')
..i('📦 Data: ${message.data}');
NotificationActionHandler.handle(message.data);
}

View File

@ -0,0 +1,42 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
class LocalNotificationService {
static final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin();
static Future<void> initialize() async {
const AndroidInitializationSettings androidInitSettings =
AndroidInitializationSettings('@mipmap/ic_launcher');
const InitializationSettings initSettings = InitializationSettings(
android: androidInitSettings,
iOS: DarwinInitializationSettings(),
);
await _notificationsPlugin.initialize(initSettings);
}
static Future<void> showNotification({
required String title,
required String body,
}) async {
const AndroidNotificationDetails androidDetails =
AndroidNotificationDetails(
'default_channel_id',
'Default Channel',
importance: Importance.max,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
);
const NotificationDetails notificationDetails =
NotificationDetails(android: androidDetails);
await _notificationsPlugin.show(
0,
title,
body,
notificationDetails,
);
}
}

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

@ -0,0 +1,430 @@
import 'package:get/get.dart';
import 'package:logger/logger.dart';
import 'package:on_field_work/controller/attendance/attendance_screen_controller.dart';
import 'package:on_field_work/controller/task_planning/daily_task_controller.dart';
import 'package:on_field_work/controller/task_Planning/daily_task_Planning_controller.dart';
import 'package:on_field_work/controller/expense/expense_screen_controller.dart';
import 'package:on_field_work/controller/expense/expense_detail_controller.dart';
import 'package:on_field_work/controller/directory/directory_controller.dart';
import 'package:on_field_work/controller/directory/notes_controller.dart';
import 'package:on_field_work/controller/document/user_document_controller.dart';
import 'package:on_field_work/controller/document/document_details_controller.dart';
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart';
/// Handles incoming FCM notification actions and updates UI/controllers.
class NotificationActionHandler {
static final Logger _logger = Logger();
/// Main entry point call this for any notification `data` map.
static void handle(Map<String, dynamic> data) {
_logger.i('📲 Handling notification action: $data');
if (data.isEmpty) {
_logger.w('⚠️ Empty notification data received.');
return;
}
final type = data['type'];
final action = data['Action'];
final keyword = data['Keyword'];
if (type != null) {
_handleByType(type, data);
} else if (keyword != null) {
_handleByKeyword(keyword, action, data);
} else {
_logger.w('⚠️ Unhandled notification: $data');
}
}
/// Handle notification if identified by `type`
static void _handleByType(String type, Map<String, dynamic> data) {
switch (type) {
case 'expense_updated':
_handleExpenseUpdated(data);
break;
case 'attendance_updated':
_handleAttendanceUpdated(data);
_handleDashboardUpdate(data); // refresh dashboard attendance
break;
case 'dashboard_update':
_handleDashboardUpdate(data); // full dashboard refresh
break;
default:
_logger.w('⚠️ Unknown notification type: $type');
}
}
/// Handle notification if identified by `keyword`
static void _handleByKeyword(
String keyword, String? action, Map<String, dynamic> data) {
switch (keyword) {
/// 🔹 Attendance
case 'Attendance':
if (_isAttendanceAction(action)) {
_handleAttendanceUpdated(data);
_handleDashboardUpdate(data);
}
break;
case 'Team_Modified':
_handleDashboardUpdate(data);
break;
/// 🔹 Tasks
case 'Report_Task':
_handleTaskUpdated(data, isComment: false);
_handleDashboardUpdate(data);
break;
case 'Task_Comment':
_handleTaskUpdated(data, isComment: true);
_handleDashboardUpdate(data);
break;
case 'Task_Modified':
case 'WorkArea_Modified':
case 'Floor_Modified':
case 'Building_Modified':
_handleTaskPlanningUpdated(data);
_handleDashboardUpdate(data);
break;
/// 🔹 Expenses
case 'Expenses_Modified':
_handleExpenseUpdated(data);
_handleDashboardUpdate(data);
break;
/// 🔹 Documents
case 'Employee_Document_Modified':
case 'Project_Document_Modified':
_handleDocumentModified(data);
break;
/// 🔹 Directory / Contacts
case 'Contact_Modified':
_handleContactModified(data);
break;
case 'Contact_Note_Modified':
_handleContactNoteModified(data);
break;
case 'Bucket_Modified':
_handleBucketModified(data);
break;
case 'Bucket_Assigned':
_handleBucketAssigned(data);
break;
default:
_logger.w('⚠️ Unhandled notification keyword: $keyword');
}
}
/// ---------------------- HANDLERS ----------------------
static void _handleTaskPlanningUpdated(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored task planning update from another project.");
return;
}
final projectId = data['ProjectId'];
if (projectId == null) {
_logger.w("⚠️ TaskPlanning update received without ProjectId: $data");
return;
}
_safeControllerUpdate<DailyTaskPlanningController>(
onFound: (controller) {
controller.fetchTaskData(projectId);
},
notFoundMessage:
'⚠️ DailyTaskPlanningController not found, cannot refresh.',
successMessage:
'✅ DailyTaskPlanningController refreshed from notification.',
);
}
static bool _isAttendanceAction(String? action) {
const validActions = {
'CHECK_IN',
'CHECK_OUT',
'REQUEST_REGULARIZE',
'REQUEST_DELETE',
'REGULARIZE',
'REGULARIZE_REJECT'
};
return validActions.contains(action);
}
static void _handleExpenseUpdated(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored expense update from another project.");
return;
}
final expenseId = data['ExpenseId'];
if (expenseId == null) {
_logger.w("⚠️ Expense update received without ExpenseId: $data");
return;
}
_safeControllerUpdate<ExpenseController>(
onFound: (controller) async {
await controller.fetchExpenses();
},
notFoundMessage: '⚠️ ExpenseController not found, cannot refresh list.',
successMessage:
'✅ ExpenseController refreshed from expense notification.',
);
_safeControllerUpdate<ExpenseDetailController>(
onFound: (controller) async {
if (controller.expense.value?.id == expenseId) {
await controller.fetchExpenseDetails();
_logger
.i("✅ ExpenseDetailController refreshed for Expense $expenseId");
}
},
notFoundMessage: ' ExpenseDetailController not active, skipping.',
successMessage: '✅ ExpenseDetailController checked for refresh.',
);
}
static void _handleAttendanceUpdated(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored attendance update from another project.");
return;
}
_safeControllerUpdate<AttendanceController>(
onFound: (controller) => controller.refreshDataFromNotification(
projectId: data['ProjectId'],
),
notFoundMessage: '⚠️ AttendanceController not found, cannot update.',
successMessage: '✅ AttendanceController refreshed from notification.',
);
}
static void _handleTaskUpdated(Map<String, dynamic> data,
{required bool isComment}) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored task update from another project.");
return;
}
_safeControllerUpdate<DailyTaskController>(
onFound: (controller) => controller.refreshTasksFromNotification(
projectId: data['ProjectId'],
taskAllocationId: data['TaskAllocationId'],
),
notFoundMessage: '⚠️ DailyTaskController not found, cannot update.',
successMessage: '✅ DailyTaskController refreshed from notification.',
);
}
/// ---------------------- DOCUMENT HANDLER ----------------------
static void _handleDocumentModified(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored document update from another project.");
return;
}
String entityTypeId;
String entityId;
String? documentId = data['DocumentId'];
if (data['Keyword'] == 'Employee_Document_Modified') {
entityTypeId = Permissions.employeeEntity;
entityId = data['EmployeeId'] ?? '';
} else if (data['Keyword'] == 'Project_Document_Modified') {
entityTypeId = Permissions.projectEntity;
entityId = data['ProjectId'] ?? '';
} else {
_logger.w("⚠️ Document update received with unknown keyword: $data");
return;
}
if (entityId.isEmpty) {
_logger.w("⚠️ Document update missing entityId: $data");
return;
}
_logger.i(
"🔔 Document notification received: keyword=${data['Keyword']}, entityTypeId=$entityTypeId, entityId=$entityId, documentId=$documentId");
if (Get.isRegistered<DocumentController>()) {
_safeControllerUpdate<DocumentController>(
onFound: (controller) async {
await controller.fetchDocuments(
entityTypeId: entityTypeId,
entityId: entityId,
reset: true,
);
},
notFoundMessage:
'⚠️ DocumentController not found, cannot refresh list.',
successMessage: '✅ DocumentController refreshed from notification.',
);
} else {
_logger.w('⚠️ DocumentController not registered, skipping list refresh.');
}
if (documentId != null && Get.isRegistered<DocumentDetailsController>()) {
_safeControllerUpdate<DocumentDetailsController>(
onFound: (controller) async {
await controller.fetchDocumentDetails(documentId);
_logger.i(
"✅ DocumentDetailsController refreshed for Document $documentId");
},
notFoundMessage:
' DocumentDetailsController not active, skipping details refresh.',
successMessage: '✅ DocumentDetailsController checked for refresh.',
);
} else if (documentId != null) {
_logger.w(
'⚠️ DocumentDetailsController not registered, cannot refresh document details.');
}
}
/// ---------------------- DIRECTORY HANDLERS ----------------------
static void _handleContactModified(Map<String, dynamic> data) {
_safeControllerUpdate<DirectoryController>(
onFound: (controller) {
controller.fetchContacts();
final contactId = data['ContactId'];
if (contactId != null) {
controller.fetchCommentsForContact(contactId);
}
},
notFoundMessage:
'⚠️ DirectoryController not found, cannot refresh contacts.',
successMessage:
'✅ Directory contacts (and notes if applicable) refreshed from notification.',
);
_safeControllerUpdate<NotesController>(
onFound: (controller) => controller.fetchNotes(),
notFoundMessage: '⚠️ NotesController not found, cannot refresh notes.',
successMessage: '✅ Notes refreshed from notification.',
);
}
static void _handleContactNoteModified(Map<String, dynamic> data) {
_handleContactModified(data);
}
static void _handleBucketModified(Map<String, dynamic> data) {
_safeControllerUpdate<DirectoryController>(
onFound: (controller) => controller.fetchBuckets(),
notFoundMessage: '⚠️ DirectoryController not found, cannot refresh.',
successMessage: '✅ Buckets refreshed from notification.',
);
}
static void _handleBucketAssigned(Map<String, dynamic> data) {
_safeControllerUpdate<DirectoryController>(
onFound: (controller) => controller.fetchBuckets(),
notFoundMessage: '⚠️ DirectoryController not found, cannot refresh.',
successMessage: '✅ Bucket assignments refreshed from notification.',
);
}
/// ---------------------- DASHBOARD HANDLER ----------------------
static void _handleDashboardUpdate(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored dashboard update from another project.");
return;
}
_safeControllerUpdate<DashboardController>(
onFound: (controller) async {
final type = data['type'] ?? '';
switch (type) {
case 'attendance_updated':
await controller.fetchRoleWiseAttendance();
break;
case 'task_updated':
await controller.fetchDashboardTasks(
projectId: controller.projectController.selectedProjectId.value,
);
break;
case 'project_progress_update':
await controller.fetchProjectProgress();
break;
case 'Employee_Suspend':
final currentProjectId =
controller.projectController.selectedProjectId.value;
final projectIdsString = data['ProjectIds'] ?? '';
final notificationProjectIds =
projectIdsString.split(',').map((e) => e.trim()).toList();
if (notificationProjectIds.contains(currentProjectId)) {
await controller.fetchDashboardTeams(projectId: currentProjectId);
}
break;
case 'Team_Modified':
final projectId = data['ProjectId'] ??
controller.projectController.selectedProjectId.value;
await controller.fetchDashboardTeams(projectId: projectId);
break;
case 'full_dashboard_refresh':
default:
await controller.refreshDashboard();
}
},
notFoundMessage: '⚠️ DashboardController not found, cannot refresh.',
successMessage: '✅ DashboardController refreshed from notification.',
);
}
/// ---------------------- 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>({
required void Function(T controller) onFound,
required String notFoundMessage,
required String successMessage,
}) {
if (!Get.isRegistered<T>()) {
_logger.w(notFoundMessage);
return;
}
try {
final controller = Get.find<T>();
onFound(controller);
_logger.i(successMessage);
} catch (e) {
_logger.w('⚠️ Error updating controller: $e');
}
}
}

View File

@ -2,28 +2,32 @@ 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/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
static final Map<String, Map<String, dynamic>> _userDataCache = {}; static final Map<String, Map<String, dynamic>> _userDataCache = {};
static const String _baseUrl = ApiEndpoints.baseUrl; static const String _baseUrl = ApiEndpoints.baseUrl;
/// Fetches all user-related data (permissions, employee info, projects) /// Fetches all user-related data (permissions, employee info, projects).
/// Uses in-memory cache for repeated token queries during session.
static Future<Map<String, dynamic>> fetchAllUserData( static Future<Map<String, dynamic>> fetchAllUserData(
String token, { String token, {
bool hasRetried = false, bool hasRetried = false,
}) async { }) async {
logSafe("Fetching user data...", ); logSafe("Fetching user data...");
if (_userDataCache.containsKey(token)) { // Check for cached data before network request
logSafe("User data cache hit.", ); final cached = _userDataCache[token];
return _userDataCache[token]!; if (cached != null) {
logSafe("User data cache hit.");
return cached;
} }
final uri = Uri.parse("$_baseUrl/user/profile"); final uri = Uri.parse("$_baseUrl/user/profile");
@ -34,8 +38,8 @@ class PermissionService {
final statusCode = response.statusCode; final statusCode = response.statusCode;
if (statusCode == 200) { if (statusCode == 200) {
logSafe("User data fetched successfully."); final raw = json.decode(response.body);
final data = json.decode(response.body)['data']; final data = raw['data'] as Map<String, dynamic>;
final result = { final result = {
'permissions': _parsePermissions(data['featurePermissions']), 'permissions': _parsePermissions(data['featurePermissions']),
@ -43,10 +47,12 @@ class PermissionService {
'projects': _parseProjectsInfo(data['projects']), 'projects': _parseProjectsInfo(data['projects']),
}; };
_userDataCache[token] = result; _userDataCache[token] = result; // Cache it for future use
logSafe("User data fetched successfully.");
return result; return result;
} }
// Token expired, try refresh once then redirect on failure
if (statusCode == 401 && !hasRetried) { if (statusCode == 401 && !hasRetried) {
logSafe("Unauthorized. Attempting token refresh...", level: LogLevel.warning); logSafe("Unauthorized. Attempting token refresh...", level: LogLevel.warning);
@ -63,42 +69,43 @@ class PermissionService {
throw Exception('Unauthorized. Token refresh failed.'); throw Exception('Unauthorized. Token refresh failed.');
} }
final error = json.decode(response.body)['message'] ?? 'Unknown error'; final errorMsg = json.decode(response.body)['message'] ?? 'Unknown error';
logSafe("Failed to fetch user data: $error", level: LogLevel.warning); logSafe("Failed to fetch user data: $errorMsg", level: LogLevel.warning);
throw Exception('Failed to fetch user data: $error'); throw Exception('Failed to fetch user data: $errorMsg');
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Exception while fetching user data", level: LogLevel.error, error: e, stackTrace: stacktrace); logSafe("Exception while fetching user data", level: LogLevel.error, error: e, stackTrace: stacktrace);
rethrow; rethrow; // Let the caller handle or report
} }
} }
/// Clears auth data and redirects to login /// Handles unauthorized/user sign out flow
static Future<void> _handleUnauthorized() async { static Future<void> _handleUnauthorized() async {
logSafe("Clearing tokens and redirecting to login due to unauthorized access.", level: LogLevel.warning); logSafe("Clearing tokens and redirecting to login due to unauthorized access.", level: LogLevel.warning);
await LocalStorage.removeToken('jwt_token'); await LocalStorage.removeToken('jwt_token');
await LocalStorage.removeToken('refresh_token'); await LocalStorage.removeToken('refresh_token');
await LocalStorage.setLoggedInUser(false); await LocalStorage.setLoggedInUser(false);
Get.offAllNamed('/auth/login-option'); Get.offAllNamed('/auth/login-option');
} }
/// Converts raw permission data into list of `UserPermission` /// Robust model parsing for permissions
static List<UserPermission> _parsePermissions(List<dynamic> permissions) { static List<UserPermission> _parsePermissions(List<dynamic> permissions) {
logSafe("Parsing user permissions..."); logSafe("Parsing user permissions...");
return permissions return permissions
.map((id) => UserPermission.fromJson({'id': id})) .map((perm) => UserPermission.fromJson({'id': perm}))
.toList(); .toList();
} }
/// Converts raw employee JSON into `EmployeeInfo` /// Robust model parsing for employee info
static EmployeeInfo _parseEmployeeInfo(Map<String, dynamic> data) { static EmployeeInfo _parseEmployeeInfo(Map<String, dynamic>? data) {
logSafe("Parsing employee info..."); logSafe("Parsing employee info...");
if (data == null) throw Exception("Employee data missing");
return EmployeeInfo.fromJson(data); return EmployeeInfo.fromJson(data);
} }
/// Converts raw projects JSON into list of `ProjectInfo` /// Robust model parsing for projects list
static List<ProjectInfo> _parseProjectsInfo(List<dynamic> projects) { static List<ProjectInfo> _parseProjectsInfo(List<dynamic>? projects) {
logSafe("Parsing projects info..."); logSafe("Parsing projects info...");
if (projects == null) return [];
return projects.map((proj) => ProjectInfo.fromJson(proj)).toList(); return projects.map((proj) => ProjectInfo.fromJson(proj)).toList();
} }
} }

View File

@ -1,13 +1,14 @@
import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/localizations/language.dart';
import 'package:marco/helpers/theme/theme_customizer.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:marco/model/user_permission.dart';
import 'package:marco/model/employee_info.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:marco/controller/project_controller.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/helpers/services/localizations/language.dart';
import 'package:on_field_work/helpers/theme/theme_customizer.dart';
import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:on_field_work/model/user_permission.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";
@ -19,124 +20,125 @@ class LocalStorage {
static const String _employeeInfoKey = "employee_info"; static const String _employeeInfoKey = "employee_info";
static const String _mpinTokenKey = "mpinToken"; static const String _mpinTokenKey = "mpinToken";
static const String _isMpinKey = "isMpin"; static const String _isMpinKey = "isMpin";
static const String _fcmTokenKey = "fcm_token";
static const String _menuStorageKey = "dynamic_menus";
// In LocalStorage
static const String _recentTenantKey = "recent_tenant_id";
static Future<bool> setRecentTenantId(String tenantId) =>
preferences.setString(_recentTenantKey, tenantId);
static String? getRecentTenantId() =>
_initialized ? preferences.getString(_recentTenantKey) : null;
static Future<bool> removeRecentTenantId() =>
preferences.remove(_recentTenantKey);
static SharedPreferences? _preferencesInstance; static SharedPreferences? _preferencesInstance;
static bool _initialized = false;
static bool get isInitialized => _initialized;
static SharedPreferences get preferences { static SharedPreferences get preferences {
if (_preferencesInstance == null) { if (_preferencesInstance == null) {
throw ("Call LocalStorage.init() to initialize local storage"); throw ("Call LocalStorage.init() before using it");
} }
return _preferencesInstance!; return _preferencesInstance!;
} }
// In LocalStorage class
static Future<bool> setUserPermissions( /// Initialization (idempotent)
List<UserPermission> permissions) async {
// Convert the list of UserPermission objects to a List<Map<String, dynamic>>
final jsonList = permissions.map((e) => e.toJson()).toList();
// Save as a JSON string
return preferences.setString(_userPermissionsKey, jsonEncode(jsonList));
}
static List<UserPermission> getUserPermissions() {
final storedJson = preferences.getString(_userPermissionsKey);
if (storedJson != null) {
final List<dynamic> parsedList = jsonDecode(storedJson);
return parsedList
.map((e) => UserPermission.fromJson(e as Map<String, dynamic>))
.toList();
}
return [];
}
static Future<bool> removeUserPermissions() async {
return preferences.remove(_userPermissionsKey);
}
// Store EmployeeInfo
static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) async {
final jsonData = employeeInfo.toJson();
return preferences.setString(_employeeInfoKey, jsonEncode(jsonData));
}
static EmployeeInfo? getEmployeeInfo() {
final storedJson = preferences.getString(_employeeInfoKey);
if (storedJson != null) {
final Map<String, dynamic> json = jsonDecode(storedJson);
return EmployeeInfo.fromJson(json);
}
return null;
}
static Future<bool> removeEmployeeInfo() async {
return preferences.remove(_employeeInfoKey);
}
// Other methods for handling JWT, refresh token, etc.
static Future<void> init() async { static Future<void> init() async {
if (_initialized) return;
_preferencesInstance = await SharedPreferences.getInstance(); _preferencesInstance = await SharedPreferences.getInstance();
await initData(); await _initData();
_initialized = true;
} }
static Future<void> initData() async { static Future<void> _initData() async {
SharedPreferences preferences = await SharedPreferences.getInstance();
AuthService.isLoggedIn = preferences.getBool(_loggedInUserKey) ?? false; AuthService.isLoggedIn = preferences.getBool(_loggedInUserKey) ?? false;
ThemeCustomizer.fromJSON(preferences.getString(_themeCustomizerKey)); ThemeCustomizer.fromJSON(preferences.getString(_themeCustomizerKey));
} }
static Future<bool> setLoggedInUser(bool loggedIn) async { // ================== Sidebar Menu ==================
return preferences.setBool(_loggedInUserKey, loggedIn); static Future<bool> setMenus(List<MenuItem> menus) async {
try {
final jsonList = menus.map((e) => e.toJson()).toList();
return preferences.setString(_menuStorageKey, jsonEncode(jsonList));
} catch (e) {
print("Error saving menus: $e");
return false;
}
} }
static Future<bool> setCustomizer(ThemeCustomizer themeCustomizer) { static List<MenuItem> getMenus() {
return preferences.setString(_themeCustomizerKey, themeCustomizer.toJSON()); if (!_initialized) return [];
final storedJson = preferences.getString(_menuStorageKey);
if (storedJson == null) return [];
try {
return (jsonDecode(storedJson) as List)
.map((e) => MenuItem.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
print("Error loading menus: $e");
return [];
}
} }
static Future<bool> setLanguage(Language language) { static Future<bool> removeMenus() => preferences.remove(_menuStorageKey);
return preferences.setString(_languageKey, language.locale.languageCode);
// ================== User Permissions ==================
static Future<bool> setUserPermissions(
List<UserPermission> permissions) async {
final jsonList = permissions.map((e) => e.toJson()).toList();
return preferences.setString(_userPermissionsKey, jsonEncode(jsonList));
} }
static String? getLanguage() { static List<UserPermission> getUserPermissions() {
return preferences.getString(_languageKey); if (!_initialized) return [];
final storedJson = preferences.getString(_userPermissionsKey);
if (storedJson == null) return [];
return (jsonDecode(storedJson) as List)
.map((e) => UserPermission.fromJson(e as Map<String, dynamic>))
.toList();
} }
static Future<bool> removeLoggedInUser() async { static Future<bool> removeUserPermissions() =>
return preferences.remove(_loggedInUserKey); preferences.remove(_userPermissionsKey);
// ================== Employee Info ==================
static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) => preferences
.setString(_employeeInfoKey, jsonEncode(employeeInfo.toJson()));
static EmployeeInfo? getEmployeeInfo() {
if (!_initialized) return null;
final storedJson = preferences.getString(_employeeInfoKey);
return storedJson == null
? null
: EmployeeInfo.fromJson(jsonDecode(storedJson));
} }
// Add methods to handle JWT and Refresh Token static Future<bool> removeEmployeeInfo() =>
static Future<bool> setToken(String key, String token) { preferences.remove(_employeeInfoKey);
return preferences.setString(key, token);
// ================== Login / Logout ==================
static Future<bool> setLoggedInUser(bool loggedIn) =>
preferences.setBool(_loggedInUserKey, loggedIn);
static Future<bool> removeLoggedInUser() =>
preferences.remove(_loggedInUserKey);
static Future<void> logout() async {
try {
final refreshToken = getRefreshToken();
final fcmToken = getFcmToken();
if (refreshToken != null && fcmToken != null) {
await AuthService.logoutApi(refreshToken, fcmToken);
}
} catch (e) {
print("Logout API error: $e");
} }
static String? getToken(String key) {
return preferences.getString(key);
}
static Future<bool> removeToken(String key) {
return preferences.remove(key);
}
// Convenience methods for getting the JWT and Refresh tokens
static String? getJwtToken() {
return getToken(_jwtTokenKey);
}
static String? getRefreshToken() {
return getToken(_refreshTokenKey);
}
static Future<bool> setJwtToken(String jwtToken) {
return setToken(_jwtTokenKey, jwtToken);
}
static Future<bool> setRefreshToken(String refreshToken) {
return setToken(_refreshTokenKey, refreshToken);
}
static Future<void> logout() async {
await removeLoggedInUser(); await removeLoggedInUser();
await removeToken(_jwtTokenKey); await removeToken(_jwtTokenKey);
await removeToken(_refreshTokenKey); await removeToken(_refreshTokenKey);
@ -144,56 +146,83 @@ static Future<void> logout() async {
await removeEmployeeInfo(); await removeEmployeeInfo();
await removeMpinToken(); await removeMpinToken();
await removeIsMpin(); await removeIsMpin();
await removeMenus();
await removeRecentTenantId();
await preferences.remove("mpin_verified"); await preferences.remove("mpin_verified");
await preferences.remove(_languageKey); await preferences.remove(_languageKey);
await preferences.remove(_themeCustomizerKey); await preferences.remove(_themeCustomizerKey);
await preferences.remove('selectedProjectId'); await preferences.remove('selectedProjectId');
if (Get.isRegistered<ProjectController>()) { if (Get.isRegistered<ProjectController>()) {
Get.find<ProjectController>().clearProjects(); Get.find<ProjectController>().clearProjects();
} }
Get.offAllNamed('/auth/login-option'); Get.offAllNamed('/auth/login-option');
} }
static Future<bool> setMpinToken(String token) { // ================== Theme & Language ==================
return preferences.setString(_mpinTokenKey, token); static Future<bool> setCustomizer(ThemeCustomizer themeCustomizer) =>
} preferences.setString(_themeCustomizerKey, themeCustomizer.toJSON());
static String? getMpinToken() { static Future<bool> setLanguage(Language language) =>
return preferences.getString(_mpinTokenKey); preferences.setString(_languageKey, language.locale.languageCode);
}
static String? getLanguage() =>
static Future<bool> removeMpinToken() { _initialized ? preferences.getString(_languageKey) : null;
return preferences.remove(_mpinTokenKey);
} // ================== Tokens ==================
static Future<bool> setToken(String key, String token) =>
// MPIN Enabled flag preferences.setString(key, token);
static Future<bool> setIsMpin(bool value) {
return preferences.setBool(_isMpinKey, value); static String? getToken(String key) =>
} _initialized ? preferences.getString(key) : null;
static bool getIsMpin() { static Future<bool> removeToken(String key) => preferences.remove(key);
return preferences.getBool(_isMpinKey) ?? false;
} static Future<bool> setJwtToken(String jwtToken) =>
setToken(_jwtTokenKey, jwtToken);
static Future<bool> removeIsMpin() {
return preferences.remove(_isMpinKey); static Future<bool> setRefreshToken(String refreshToken) =>
} setToken(_refreshTokenKey, refreshToken);
static Future<bool> setBool(String key, bool value) async { static String? getJwtToken() => getToken(_jwtTokenKey);
return preferences.setBool(key, value);
} static String? getRefreshToken() => getToken(_refreshTokenKey);
static bool? getBool(String key) { // ================== FCM Token ==================
return preferences.getBool(key); static Future<void> setFcmToken(String token) =>
} preferences.setString(_fcmTokenKey, token);
// Save and retrieve String values
static String? getString(String key) { static String? getFcmToken() =>
return preferences.getString(key); _initialized ? preferences.getString(_fcmTokenKey) : null;
}
// ================== MPIN ==================
static Future<bool> saveString(String key, String value) async { static Future<bool> setMpinToken(String token) =>
return preferences.setString(key, value); preferences.setString(_mpinTokenKey, token);
}
static String? getMpinToken() =>
_initialized ? preferences.getString(_mpinTokenKey) : null;
static Future<bool> removeMpinToken() => preferences.remove(_mpinTokenKey);
static Future<bool> setIsMpin(bool value) =>
preferences.setBool(_isMpinKey, value);
static bool getIsMpin() =>
_initialized ? preferences.getBool(_isMpinKey) ?? false : false;
static Future<bool> removeIsMpin() => preferences.remove(_isMpinKey);
// ================== Generic Set/Get ==================
static Future<bool> setBool(String key, bool value) =>
preferences.setBool(key, value);
static bool? getBool(String key) =>
_initialized ? preferences.getBool(key) : null;
static String? getString(String key) =>
_initialized ? preferences.getString(key) : null;
static Future<bool> saveString(String key, String value) =>
preferences.setString(key, value);
} }

View File

@ -0,0 +1,173 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:get/get.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/services/api_endpoints.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/model/tenant/tenant_list_model.dart';
/// Abstract interface for tenant service functionality
abstract class ITenantService {
Future<List<Map<String, dynamic>>?> getTenants({bool hasRetried = false});
Future<bool> selectTenant(String tenantId, {bool hasRetried = false});
}
/// Tenant API service
class TenantService implements ITenantService {
static const String _baseUrl = ApiEndpoints.baseUrl;
static const Map<String, String> _headers = {
'Content-Type': 'application/json',
};
/// Currently selected tenant
static Tenant? currentTenant;
/// Set the selected tenant
static void setSelectedTenant(Tenant tenant) {
currentTenant = tenant;
}
/// Check if tenant is selected
static bool get isTenantSelected => currentTenant != null;
/// Build authorized headers
static Future<Map<String, String>> _authorizedHeaders() async {
final token = await LocalStorage.getJwtToken();
if (token == null || token.isEmpty) {
throw Exception('Missing JWT token');
}
return {..._headers, 'Authorization': 'Bearer $token'};
}
/// Handle API errors
static void _handleApiError(
http.Response response, dynamic data, String context) {
final message = data['message'] ?? 'Unknown error';
final level =
response.statusCode >= 500 ? LogLevel.error : LogLevel.warning;
logSafe("$context failed: $message [Status: ${response.statusCode}]",
level: level);
}
/// Log exceptions
static void _logException(dynamic e, dynamic st, String context) {
logSafe("$context exception",
level: LogLevel.error, error: e, stackTrace: st);
}
@override
Future<List<Map<String, dynamic>>?> getTenants(
{bool hasRetried = false}) async {
try {
final headers = await _authorizedHeaders();
final response = await http.get(
Uri.parse("$_baseUrl/auth/get/user/tenants"),
headers: headers,
);
// Handle empty response BEFORE decoding
if (response.body.isEmpty || response.body.trim().isEmpty) {
logSafe("❌ Empty tenant response — auto logout");
await LocalStorage.logout();
return null;
}
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) {
final refreshed = await AuthService.refreshToken();
if (refreshed) return getTenants(hasRetried: true);
return null;
}
_handleApiError(response, data, "Fetching tenants");
return null;
} catch (e, st) {
_logException(e, st, "Get Tenants API");
return null;
}
}
@override
Future<bool> selectTenant(String tenantId, {bool hasRetried = false}) async {
try {
final headers = await _authorizedHeaders();
logSafe(
"➡️ POST $_baseUrl/auth/select-tenant/$tenantId\nHeaders: $headers",
level: LogLevel.info);
final response = await http.post(
Uri.parse("$_baseUrl/auth/select-tenant/$tenantId"),
headers: headers,
);
final data = jsonDecode(response.body);
logSafe(
"⬅️ Response: ${jsonEncode(data)} [Status: ${response.statusCode}]",
level: LogLevel.info);
if (response.statusCode == 200 && data['success'] == true) {
await LocalStorage.setJwtToken(data['data']['token']);
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
logSafe("✅ Tenant selected successfully. Tokens updated.");
// 🔥 Refresh projects when tenant changes
try {
final projectController = Get.find<ProjectController>();
projectController.clearProjects();
projectController.fetchProjects();
} catch (_) {
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;
}
if (response.statusCode == 401 && !hasRetried) {
logSafe("⚠️ Unauthorized while selecting tenant. Refreshing token...",
level: LogLevel.warning);
final refreshed = await AuthService.refreshToken();
if (refreshed) return selectTenant(tenantId, hasRetried: true);
logSafe("❌ Token refresh failed while selecting tenant.",
level: LogLevel.error);
return false;
}
_handleApiError(response, data, "Selecting tenant");
return false;
} catch (e, st) {
_logException(e, st, "Select Tenant API");
return false;
}
}
}

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