Compare commits

...

292 Commits

Author SHA1 Message Date
9ec7dee0f1 Merge pull request 'Collection_Purchase_Widget' (#87) from Collection_Purchase_Widget into main
Reviewed-on: #87
2025-12-06 05:29:39 +00:00
48a96a703b addedapi for purchase invoice 2025-12-05 17:24:57 +05:30
8686d696f0 improved dashboard controller 2025-12-05 17:00:22 +05:30
5d73fd6f4f added api for dashboard for collection widget 2025-12-05 16:52:24 +05:30
1717cd5e2b added mytext 2025-12-05 10:53:36 +05:30
717f0c92af feat: Add CompactPurchaseInvoiceDashboard widget and integrate into dashboard screen
- Implemented a new widget for displaying purchase invoice metrics with internal dummy data.
- Integrated the CompactPurchaseInvoiceDashboard into the dashboard screen layout.
- Updated imports in dashboard_screen.dart to include the new purchase invoice dashboard widget.
2025-12-04 17:54:48 +05:30
633a75fe92 fix: Update base URL in ApiEndpoints and increment app version to 1.0.0+18 2025-12-03 17:38:20 +05:30
8fb32c7c8e Refactor dashboard screen layout and improve loading state handling
- Simplified initialization of DynamicMenuController.
- Added loading skeleton for employee quick action cards.
- Removed daily task planning and daily progress report from card order.
- Adjusted grid layout parameters for better responsiveness.
- Cleaned up code formatting for improved readability.
2025-12-03 17:08:25 +05:30
3dfa6e5877 feat: Add infrastructure project details and list models
- Implemented ProjectDetailsResponse and ProjectData models for handling project details.
- Created ProjectsResponse and ProjectsPageData models for listing infrastructure projects.
- Added InfraProjectScreen and InfraProjectDetailsScreen for displaying project information.
- Integrated search functionality in InfraProjectScreen to filter projects.
- Updated DailyTaskPlanningScreen and DailyProgressReportScreen to accept projectId as a parameter.
- Removed unnecessary dependencies and cleaned up code for better maintainability.
2025-12-03 16:49:46 +05:30
03e3e7b5db feat: Enhance Dashboard with Attendance and Infra Projects
- Added employee attendance fetching in DashboardController.
- Introduced loading state for employees in the dashboard.
- Updated API endpoints to include attendance for the dashboard.
- Created a new InfraProjectsMainScreen with tab navigation for task planning and progress reporting.
- Improved UI components for better user experience in the dashboard.
- Refactored project selection and quick actions in the dashboard.
- Added permission constants for infrastructure projects.
2025-12-03 13:09:48 +05:30
cf85c17d75 Update base URL in ApiEndpoints to point to the correct API endpoint 2025-12-01 15:13:44 +05:30
66445b1e54 Add dynamic app version display in WelcomeScreen 2025-12-01 15:08:22 +05:30
7c86d0c5c2 Refactor connectivity handling in MainWrapper and enhance offline experience in MyApp 2025-12-01 15:04:59 +05:30
012d40cd57 Implement job comments feature: add comment widget, API endpoints, and controller methods for fetching and posting comments 2025-12-01 14:38:58 +05:30
d4d678d98a Remove padding from card wrapper and comment out quick actions in dashboard for layout adjustments 2025-11-29 15:33:49 +05:30
6ed069d924 Replace CircularProgressIndicator with skeleton loaders in dashboard; reintroduce project dropdown list with search functionality 2025-11-29 15:09:10 +05:30
c9e73771b0 Refactor CustomAppBar to StatefulWidget; implement project selection dropdown and improve UI interactions 2025-11-29 15:02:03 +05:30
ed2eb014d8 Refactor attendance management screens for improved readability and g 2025-11-29 14:36:44 +05:30
3ad48016f3 Enhance splash screen with improved animations and loading indicator; update logo size and message for better visibility 2025-11-29 12:34:30 +05:30
37ce612fca Enhance attendance management with tabbed navigation and permission handling; improve UI consistency and loading states 2025-11-29 12:34:22 +05:30
7bef2e9d89 Add job status management to service project details screen 2025-11-28 18:30:08 +05:30
341d779499 Refactor service project job handling and improve tag management 2025-11-28 15:34:13 +05:30
65fbef3441 Enhance UI and Navigation
- Added navigation to the dashboard after applying the theme in ThemeController.
- Introduced a new PillTabBar widget for a modern tab design across multiple screens.
- Updated dashboard screen to improve button actions and UI consistency.
- Refactored contact detail screen to streamline layout and enhance gradient effects.
- Implemented PillTabBar in directory main screen, expense screen, and payment request screen for consistent tab navigation.
- Improved layout structure in user document screen and employee profile screen for better user experience.
- Enhanced service project details screen with a modern tab bar implementation.
2025-11-28 14:48:39 +05:30
259f2aa928 Refactor UI components to use CustomAppBar and improve layout consistency
- Replaced existing AppBar implementations with CustomAppBar in multiple screens including PaymentRequestDetailScreen, PaymentRequestMainScreen, ServiceProjectDetailsScreen, JobDetailsScreen, DailyProgressReportScreen, DailyTaskPlanningScreen, and ServiceProjectScreen.
- Enhanced visual hierarchy by adding gradient backgrounds behind app bars for better aesthetics.
- Streamlined SafeArea usage to ensure proper content display across different devices.
- Improved code readability and maintainability by removing redundant code and consolidating UI elements.
2025-11-27 19:07:24 +05:30
84156167ea Merge pull request 'Dev_Manish_Bug' (#85) from Dev_Manish_Bug into main
Reviewed-on: #85
2025-11-27 05:31:48 +00:00
260b762724 Merge branch 'Dev_Manish_Bug' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Dev_Manish_Bug 2025-11-27 10:55:29 +05:30
951dd22ecc bug fixed for 1776, 1775, 1736, 1626 2025-11-27 10:55:12 +05:30
33ae5c0333 bug fixed for 1776, 1775, 1736, 1626 2025-11-26 15:20:20 +05:30
60bc53afef refactor: remove project selection from layout and update dashboard layout 2025-11-25 18:13:22 +05:30
90a3a85753 Merge pull request 'Dev_Manish_24/11' (#84) from Dev_Manish_24/11 into main
Reviewed-on: #84
2025-11-25 10:13:59 +00:00
71a48750bd Merge branch 'Dev_Manish_24/11' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Dev_Manish_24/11 2025-11-25 15:32:10 +05:30
e2bee52820 update screen with employee should place at same place after selecting 2025-11-25 15:32:01 +05:30
bb723b91e5 height change to 0.85 2025-11-25 15:32:01 +05:30
55122b5b13 update for tag should submit after space 2025-11-25 15:32:00 +05:30
2700864adf added safe area to support mobile screen horizontally 2025-11-25 15:32:00 +05:30
18fbfaa42d enhacement of UI for mobile screen responsiveness 2025-11-25 15:32:00 +05:30
3e8bd1c41d tag can submit after space 2025-11-25 15:32:00 +05:30
4d2b05cdc2 multiple tags can add using space 2025-11-25 15:32:00 +05:30
ddb1440211 implemented new multi select role bottom sheet 2025-11-25 15:32:00 +05:30
e4f55d82f7 reenhacenment of employee selector 2025-11-25 15:32:00 +05:30
081849f964 added needed vaiables for employee selector 2025-11-25 15:32:00 +05:30
a3b95b4d07 reenhacement of employee selector 2025-11-25 15:32:00 +05:30
ed4a558894 reenhancement of employee selector 2025-11-25 15:32:00 +05:30
e2897e4dde reenhancement of employee selector 2025-11-25 15:32:00 +05:30
08777176df update screen with employee should place at same place after selecting 2025-11-25 15:30:06 +05:30
81f74004b8 height change to 0.85 2025-11-25 13:58:47 +05:30
3fa578f1b4 feat: update Kotlin plugin version and modify API endpoints for improved functionality 2025-11-25 13:19:00 +05:30
28c1c36e07 update for tag should submit after space 2025-11-25 13:18:15 +05:30
24bfccfdf6 added safe area to support mobile screen horizontally 2025-11-25 12:45:52 +05:30
5bed5bd2f4 enhacement of UI for mobile screen responsiveness 2025-11-25 12:17:45 +05:30
41112a3eea tag can submit after space 2025-11-24 16:44:09 +05:30
9eb72a60ac multiple tags can add using space 2025-11-24 15:42:38 +05:30
b401e98658 implemented new multi select role bottom sheet 2025-11-24 15:28:31 +05:30
56602328ca reenhacenment of employee selector 2025-11-24 15:15:35 +05:30
aece165c38 added needed vaiables for employee selector 2025-11-24 15:10:31 +05:30
38626ebef0 reenhacement of employee selector 2025-11-24 15:08:03 +05:30
6b58085434 reenhancement of employee selector 2025-11-24 15:00:52 +05:30
5e1379b74b reenhancement of employee selector 2025-11-24 14:56:09 +05:30
c69e4280ef Merge pull request 'Service_Project_Branching' (#82) from Service_Project_Branching into main
Reviewed-on: #82
2025-11-24 06:32:57 +00:00
761cd1f7e8 chore: update version number to 1.0.0+16 in pubspec.yaml 2025-11-24 10:28:02 +05:30
516d6b0489 feat: enhance notification action handling and add refresh indicators in ServiceProjectDetailsScreen 2025-11-22 16:17:17 +05:30
e9075dcdf5 feat: add project name support in CustomAppBar and related screens for improved context 2025-11-22 15:15:38 +05:30
5c53a3f4be Refactor project structure and rename from 'marco' to 'on field work'
- Updated import paths across multiple files to reflect the new package name.
- Changed application name and identifiers in CMakeLists.txt, Runner.rc, and other configuration files.
- Modified web index.html and manifest.json to update the app title and name.
- Adjusted macOS and Windows project settings to align with the new application name.
- Ensured consistency in naming across all relevant files and directories.
2025-11-22 14:20:37 +05:30
603e7ee7e5 feat: add advance payment indicator in payment request screens and improve code formatting 2025-11-21 16:57:37 +05:30
2c98ac359c feat: enhance payment request data models for null safety and improve filter options handling in PaymentRequestController 2025-11-21 16:42:33 +05:30
fc78806af2 feat: implement job search functionality and archived job toggle in JobsTab; refactor ServiceProjectDetailsScreen to integrate JobsTab 2025-11-21 16:33:09 +05:30
cf7021a982 feat: refactor ExpenseDetailScreen to use tagged controller for improved state management 2025-11-20 18:02:03 +05:30
efefb1c34b feat: add app version display to user profile bar and update version number in pubspec.yaml 2025-11-20 17:19:48 +05:30
846ca64402 feat: update project fetching method to use getGlobalProjects for improved data retrieval 2025-11-20 16:52:51 +05:30
765f537cf9 feat: enhance tenant fetching logic to handle empty responses and invalid JSON gracefully 2025-11-20 16:33:08 +05:30
87a7d19672 feat: enhance job detail screen to display project branch information and improve payment request handling 2025-11-20 16:21:41 +05:30
d0b40a9822 feat: enhance JSON parsing in ServiceProjectAllocationResponse and related models to handle null values gracefully 2025-11-20 11:55:15 +05:30
c44d10d35a feat: update JobDetailsResponse and JobData models to support nullable fields and improve JSON parsing 2025-11-20 11:12:59 +05:30
02ef996753 feat: update projectBranchId field in API service and increment version to 1.0.0+14 2025-11-20 09:58:36 +05:30
ec6a45ed43 feat: integrate snackbar notifications for document and job updates in user document controller and job detail screen 2025-11-19 17:29:10 +05:30
92c739045c feat: rename Expense Type to Expense Category across controllers, models, and views for consistency 2025-11-19 17:20:44 +05:30
5dd09869ad feat: enhance ExpenseDetailModel and ExpenseDetailScreen with additional fields and improved UI structure 2025-11-19 17:17:04 +05:30
8edd189479 feat: add branch selection functionality and API integration for service project jobs 2025-11-19 16:36:32 +05:30
bbadcc4139 feat: update API endpoints and enhance attendance log handling in job detail screen 2025-11-19 15:44:55 +05:30
d05e26bc87 feat: enhance date range picker logic to restrict date selection and improve validation 2025-11-19 12:19:47 +05:30
49326e9929 feat: enhance employee selection and validation in service project job form 2025-11-19 12:00:26 +05:30
0e575c393d feat: enhance job fetching logic and add auto-refresh after job addition 2025-11-19 11:28:39 +05:30
804f0eba7b enhansed ui 2025-11-18 17:50:47 +05:30
253aa55a80 added manage team 2025-11-18 17:44:12 +05:30
17fc04f3ee feat: add cached_network_image dependency and improve image handling in attendance logs 2025-11-18 12:11:30 +05:30
474ecac53c refactor: improve null safety in employee details and enhance UI handling 2025-11-18 11:17:33 +05:30
dc4ea7979c resoled payment request model crash issue 2025-11-18 10:57:46 +05:30
618ac6f27a chore: update package name and bundle identifier to com.marcoonfieldwork.aiot 2025-11-17 20:17:14 +05:30
2260382990 refactor: enhance job attendance handling and improve null safety in models 2025-11-17 18:03:59 +05:30
919310644d added tag in tag out api and code 2025-11-17 17:36:11 +05:30
605617695e refactor: improve code readability and consistency in JobDetailsScreen 2025-11-17 16:02:03 +05:30
4d47ee3002 fixed issues 2025-11-17 15:23:51 +05:30
9dd66bd297 Merge pull request 'OH_Dev_Manish' (#81) from OH_Dev_Manish into Service_Project_Feature
Reviewed-on: #81
2025-11-17 09:48:29 +00:00
f506bd1bfc Merge branch 'OH_Dev_Manish' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into OH_Dev_Manish 2025-11-17 15:16:01 +05:30
39abfa2260 Rebase issues solved 2025-11-17 15:15:13 +05:30
ed88e0dfc9 implementation of Manage reporting 2025-11-17 15:14:48 +05:30
a910d37f22 All Employees fetching task done in advance payment screen 2025-11-17 15:14:13 +05:30
2df4afcea7 implementation of manage reporting inside employee profile 2025-11-17 15:14:13 +05:30
ceee12df33 implementation of Manage reporting 2025-11-17 15:14:12 +05:30
58ed2676e3 implementation of Manage reporting 2025-11-17 15:14:12 +05:30
e1b6794e15 made chnages in details screen and list screen 2025-11-17 15:14:12 +05:30
01f3cd24da added service projet screen 2025-11-17 15:14:12 +05:30
a8b31b3a95 All Employees fetching task done in advance payment screen 2025-11-17 15:13:51 +05:30
b9195c7fdd .. 2025-11-17 15:13:31 +05:30
be835bd2bd implementation of manage reporting inside employee profile 2025-11-17 15:13:31 +05:30
2d35addeeb implementation of Manage reporting 2025-11-17 15:13:31 +05:30
e3fe3c0d86 implementation of manage reporting inside employee profile 2025-11-17 15:13:31 +05:30
b8aefc544f implementation of Manage reporting 2025-11-17 15:13:30 +05:30
58c6a3f9af All Employees fetching task done in advance payment screen 2025-11-17 15:13:30 +05:30
1f694381a1 .. 2025-11-17 15:13:30 +05:30
ba6d58fd0a implementation of manage reporting inside employee profile 2025-11-17 15:13:30 +05:30
e9a43af350 implementation of Manage reporting 2025-11-17 15:13:15 +05:30
fdd2d51ed0 implementation of manage reporting inside employee profile 2025-11-17 15:12:52 +05:30
cddc2990fb created new emploee selector bottomsheet 2025-11-17 15:12:22 +05:30
3974ae8a4c Merge branch 'OH_Dev_Manish' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into OH_Dev_Manish 2025-11-17 15:09:21 +05:30
68ed97b64a Rebase issues solved 2025-11-17 15:08:18 +05:30
793eeec7fa Rebase issues solved 2025-11-17 15:07:46 +05:30
29af4e53a9 implementation of Manage reporting 2025-11-17 15:07:13 +05:30
0b894a5091 All Employees fetching task done in advance payment screen 2025-11-17 15:06:39 +05:30
8153d315b7 implementation of manage reporting inside employee profile 2025-11-17 15:06:39 +05:30
bcfa29c0ed implementation of Manage reporting 2025-11-17 15:06:39 +05:30
7f7d73c790 implementation of Manage reporting 2025-11-17 15:06:38 +05:30
7fdeb103cf made chnages in details screen and list screen 2025-11-17 15:06:38 +05:30
3bae3429c6 added service projet screen 2025-11-17 15:06:38 +05:30
79b727aeb1 implementation of Manage reporting 2025-11-17 15:05:39 +05:30
52da540271 Merge branch 'Service_Project_Feature' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Service_Project_Feature 2025-11-17 15:00:04 +05:30
b2619eabe9 added edit job fucntioanllity 2025-11-17 14:59:01 +05:30
b21c00faac added service project job details screen 2025-11-17 14:58:27 +05:30
083eece75b added edit job api 2025-11-17 14:56:29 +05:30
bc62bc903d added service project job details screen 2025-11-17 14:56:29 +05:30
a39af6519a added edit job api 2025-11-17 14:56:29 +05:30
d530077444 added service project job details screen 2025-11-17 14:56:28 +05:30
a2aef3355f fixed the model crashing 2025-11-17 14:56:05 +05:30
de657a4bce All Employees fetching task done in advance payment screen 2025-11-17 14:56:04 +05:30
4c9fecdfcf UI Enhancements in Finance Module – Payment Request & Expense Screens 2025-11-17 14:50:59 +05:30
52782098f8 All Employees fetching task done in advance payment screen 2025-11-17 14:50:59 +05:30
fc09673cd0 .. 2025-11-17 14:50:59 +05:30
bd6fc9f81b implementation of manage reporting inside employee profile 2025-11-17 14:50:59 +05:30
3660291482 implementation of Manage reporting 2025-11-17 14:50:59 +05:30
81692a1930 implementation of Manage reporting 2025-11-17 14:50:59 +05:30
5c386cb8ff implementation of manage reporting inside employee profile 2025-11-17 14:50:58 +05:30
0334a6b7a8 implementation of Manage reporting 2025-11-17 14:50:58 +05:30
38528f1ade removed unused code 2025-11-17 14:50:58 +05:30
8619a9e43e created new emploee selector bottomsheet 2025-11-17 14:50:58 +05:30
5a3e9c2977 corrected the payment proceed validation 2025-11-17 14:50:58 +05:30
9f72f689c5 made chnages into dynamic menus 2025-11-17 14:50:58 +05:30
c8a8d45c66 made chnages in details screen and list screen 2025-11-17 14:50:57 +05:30
10e9f4a315 added edit job fucntioanllity 2025-11-17 14:49:44 +05:30
5ce37379cb Merge branch 'OH_Dev_Manish' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into OH_Dev_Manish 2025-11-17 14:47:42 +05:30
d5868d213a Rebase issues solved 2025-11-17 14:46:33 +05:30
14ed2a8417 implementation of Manage reporting 2025-11-17 14:41:46 +05:30
55fffbbe78 implementation of Manage reporting 2025-11-17 14:40:51 +05:30
6f8f1f1856 All Employees fetching task done in advance payment screen 2025-11-17 14:39:22 +05:30
548ecb33a6 implementation of manage reporting inside employee profile 2025-11-17 14:39:22 +05:30
01d7a2fd3b implementation of Manage reporting 2025-11-17 14:39:09 +05:30
8dabac2eff implementation of Manage reporting 2025-11-17 14:38:26 +05:30
b476986807 made chnages in details screen and list screen 2025-11-17 14:36:14 +05:30
2149fd6bc5 added service projet screen 2025-11-17 14:36:00 +05:30
30405f7de4 Merge branch 'OH_Dev_Manish' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into OH_Dev_Manish 2025-11-17 14:30:24 +05:30
ffb02027cc Rebase issues solved 2025-11-17 13:02:16 +05:30
d3b8ce17b8 All Employees fetching task done in advance payment screen 2025-11-17 12:58:43 +05:30
e024cf4576 .. 2025-11-17 12:54:15 +05:30
8edf3e89a5 implementation of manage reporting inside employee profile 2025-11-17 12:54:15 +05:30
a61b9c50af implementation of Manage reporting 2025-11-17 12:48:51 +05:30
509813ca2d implementation of manage reporting inside employee profile 2025-11-17 12:46:27 +05:30
61ca8f4250 implementation of Manage reporting 2025-11-17 12:46:27 +05:30
36a3d586a0 UI Enhancements in Finance Module – Payment Request & Expense Screens 2025-11-17 12:44:59 +05:30
899252f215 All Employees fetching task done in advance payment screen 2025-11-17 12:44:59 +05:30
411e9afcc9 .. 2025-11-17 12:44:59 +05:30
f3f1f02282 implementation of manage reporting inside employee profile 2025-11-17 12:44:59 +05:30
477667c06a implementation of Manage reporting 2025-11-17 12:44:59 +05:30
575fcc200c implementation of Manage reporting 2025-11-17 12:44:59 +05:30
1b883ac524 implementation of manage reporting inside employee profile 2025-11-17 12:44:59 +05:30
c631f8b092 implementation of Manage reporting 2025-11-17 12:44:59 +05:30
bdfc492e65 removed unused code 2025-11-17 12:44:59 +05:30
674a9c691b created new emploee selector bottomsheet 2025-11-17 12:44:58 +05:30
02c10d8115 corrected the payment proceed validation 2025-11-17 12:44:58 +05:30
456df30c8e made chnages into dynamic menus 2025-11-17 12:44:58 +05:30
d229facfba made chnages in details screen and list screen 2025-11-17 12:44:58 +05:30
4900ce87f7 Rebase issues solved 2025-11-17 11:32:07 +05:30
3ebc9ab602 Merge branch 'Service_Project_Feature' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Service_Project_Feature 2025-11-17 11:25:21 +05:30
7e3d2ac2b9 added edit job api 2025-11-17 11:25:16 +05:30
7ee65115be added service project job details screen 2025-11-17 11:24:37 +05:30
e1952d505b modified api service 2025-11-17 11:22:31 +05:30
2b42393741 Merge branch 'Service_Project_Feature' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Service_Project_Feature 2025-11-17 11:17:21 +05:30
ffd18a9e40 added edit job api 2025-11-17 11:17:12 +05:30
9ef1f57ca4 added service project job details screen 2025-11-17 11:17:12 +05:30
6e37e0dd04 fixed the model crashing 2025-11-17 11:16:16 +05:30
ee1e5014b4 added edit job api 2025-11-17 11:15:17 +05:30
31a27da85d added service project job details screen 2025-11-17 11:08:50 +05:30
2e750ad19c Merge branch 'OH_Dev_Manish' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into OH_Dev_Manish 2025-11-17 11:03:29 +05:30
5f99827b23 UI Enhancements in Finance Module – Payment Request & Expense Screens 2025-11-17 10:51:18 +05:30
70c8160dce All Employees fetching task done in advance payment screen 2025-11-17 10:51:18 +05:30
831aacc202 .. 2025-11-17 10:49:11 +05:30
fe4a64e4d2 implementation of manage reporting inside employee profile 2025-11-17 10:49:11 +05:30
2d4d71b847 implementation of Manage reporting 2025-11-17 10:49:11 +05:30
03b16e0ad9 implementation of Manage reporting 2025-11-17 10:39:28 +05:30
c0b4a74f87 implementation of manage reporting inside employee profile 2025-11-17 10:07:32 +05:30
b0472503d1 implementation of Manage reporting 2025-11-17 10:07:32 +05:30
9666d39d5e fixed the model crashing 2025-11-15 15:35:56 +05:30
befeef3c02 removed unused code 2025-11-14 15:36:32 +05:30
1f47a55d9c created new emploee selector bottomsheet 2025-11-14 15:35:41 +05:30
3bc7b5092f UI Enhancements in Finance Module – Payment Request & Expense Screens 2025-11-13 17:59:51 +05:30
2255ae29fa All Employees fetching task done in advance payment screen 2025-11-13 15:27:13 +05:30
214816ac0f corrected the payment proceed validation 2025-11-13 12:26:39 +05:30
c4bb8331c9 .. 2025-11-13 11:30:36 +05:30
00aa53d496 Merge branch 'OH_Dev_Manish' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into OH_Dev_Manish 2025-11-13 10:54:25 +05:30
818e0a144a implementation of manage reporting inside employee profile 2025-11-13 10:51:32 +05:30
0b60276e03 implementation of Manage reporting 2025-11-13 10:50:05 +05:30
68f17214fd made chnages into dynamic menus 2025-11-12 17:58:33 +05:30
cdba511d43 implementation of manage reporting inside employee profile 2025-11-12 17:17:01 +05:30
6c14fc1507 made chnages in details screen and list screen 2025-11-12 16:48:53 +05:30
feb12862fa Merge branch 'Service_Project_Feature' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Service_Project_Feature 2025-11-12 16:00:25 +05:30
1de5e7fae7 added service projet screen 2025-11-12 16:00:20 +05:30
7b6520597e added routes 2025-11-12 15:58:48 +05:30
11393922e2 Chnaged the Expense Naming and Dashboard menu handeling and chart modifications 2025-11-12 15:58:29 +05:30
cb00911983 added service projet screen 2025-11-12 15:36:10 +05:30
0414a109be Merge branch 'OH_Dev_Manish' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into OH_Dev_Manish 2025-11-12 12:16:27 +05:30
340d0b8a1e implementation of Manage reporting 2025-11-12 12:16:05 +05:30
8fb65d31e2 Merge branch 'Service_Project_Feature' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Service_Project_Feature 2025-11-12 12:07:10 +05:30
7f756f3d4c implementation of Manage reporting 2025-11-12 12:04:35 +05:30
d2c7f92a02 added routes 2025-11-12 12:04:12 +05:30
6b56351a49 displayed possible filds in details screen 2025-11-12 11:53:43 +05:30
5db53c29df added validation for payment request in and expense reumbersemrnt bottomsheet 2025-11-12 11:23:13 +05:30
be2f97cc0e fixed the make expense button display issue on creaying expense also 2025-11-11 19:34:54 +05:30
44bbbf9bfb fixed the assign project to eployeee issue after creating employee and added fabicon on expense details screen for assign to project 2025-11-11 19:21:53 +05:30
f62b7d924b fixed add and edit expense issue 2025-11-11 18:50:13 +05:30
ee9c94dc86 fixed the wrong time display 2025-11-11 18:07:20 +05:30
3e99fc67a3 added widgets in finance screen 2025-11-11 17:25:42 +05:30
cffa4456b9 fixed wrong list display due to date format , and added tds calculation in the payment proceed sheet 2025-11-11 17:03:06 +05:30
af5bfcaa59 replaced fab icon with icon on page 2025-11-11 16:12:13 +05:30
7f0c4d075d fixed the no data building are dispalyed 2025-11-11 15:27:19 +05:30
fe950c5b07 fixed pegination and for rejected status no data displayed 2025-11-11 14:55:37 +05:30
034de5a601 added permission for create payment request button 2025-11-11 14:38:05 +05:30
77a6d50571 added dynamic menu for finance cards as per expense title 2025-11-11 14:31:16 +05:30
077dbf35ee added routes 2025-11-11 14:21:54 +05:30
f232ab42b0 Merge branch 'Service_Project_Feature' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Service_Project_Feature 2025-11-11 14:21:01 +05:30
3543770654 made chnages to expense category of type 2025-11-11 12:53:55 +05:30
1dc68033df fixed category issue while updating and adding expense 2025-11-11 12:48:57 +05:30
b1f5fb8d78 chnaged expense type to category 2025-11-10 16:39:25 +05:30
8d688f2575 fixed date pickering issue 2025-11-10 16:22:44 +05:30
502bb1e2d9 fixed add remove doscument 2025-11-10 16:10:45 +05:30
a88d085001 fixed the loading issue 2025-11-10 14:38:10 +05:30
fd57686c8a corrected the attachment issue 2025-11-10 12:57:32 +05:30
fd861b3adb chnaged make expense flow 2025-11-10 12:08:07 +05:30
1c253df5f9 fixed the expense screen neigation build issue 2025-11-10 10:26:03 +05:30
92f7fec083 added payment request screens 2025-11-08 16:18:26 +05:30
1070f04d1a added finance code 2025-11-08 15:46:28 +05:30
eb46194679 made chnages in employee details screen 2025-11-08 12:17:17 +05:30
910bb5e6b4 removed employees by project 2025-11-08 11:53:34 +05:30
80d7ef96cb feat: Add daily progress planning skeleton loader and improve UI formatting 2025-11-04 17:41:29 +05:30
99166801da feat: Implement lazy loading for building infrastructure and enhance task fetching logic 2025-11-04 17:07:54 +05:30
87520d7664 fix: Adjust spacing and add today's planned tasks display in daily task planning 2025-11-04 16:26:33 +05:30
e8d4931016 fix: Enhance employee sorting by adding latest entry comparison after action priority 2025-11-04 15:45:53 +05:30
f0d42edcc1 fix: Update regularization time picker to use reference time and allow past times for regularization 2025-11-04 15:18:42 +05:30
82c2cc3c58 feat: Improve task data fetching by clearing grouped tasks on new load and preventing duplicates 2025-11-04 14:52:17 +05:30
ac7a75c92f Refactor date handling in controllers and UI components
- Updated `user_document_controller.dart` to use DateTime for start and end dates.
- Enhanced `daily_task_controller.dart` with Rx fields for date range and added methods to update date ranges.
- Reverted API base URL in `api_endpoints.dart` to stage environment.
- Introduced `date_range_picker.dart` widget for reusable date selection.
- Integrated `DateRangePickerWidget` in attendance and daily task filter bottom sheets.
- Simplified date range selection logic in `attendance_filter_sheet.dart`, `daily_progress_report_filter.dart`, and `user_document_filter_bottom_sheet.dart`.
- Updated expense filter bottom sheet to utilize the new date range picker.
2025-11-04 14:15:21 +05:30
b1437db9e0 fix: Update text label in attendance filter bottom sheet for clarity 2025-11-03 17:46:33 +05:30
4d3a42e851 feat: Update image picker section to use dynamic primary color for icons and buttons 2025-11-03 17:07:15 +05:30
c78c14e409 refactor: Clean up attendance controller initialization and enhance project change handling 2025-11-03 16:43:51 +05:30
ebc8996f71 feat: Enhance monthly expense dashboard with expense type filtering functionality 2025-11-03 15:00:59 +05:30
3c95583a23 feat: Add monthly expense reporting functionality with dashboard integration 2025-11-03 14:24:39 +05:30
91174dd960 fix: Adjust padding and inner radius for expense donut chart based on mobile layout 2025-11-03 10:25:58 +05:30
9e52e50cc9 fix: Change ProjectController injection method from Get.find to Get.put for proper dependency management 2025-11-03 10:14:49 +05:30
177f8c32e2 feat: Add Expense By Status Widget and related models
- Implemented ExpenseByStatusWidget to display expenses categorized by status.
- Added ExpenseReportResponse, ExpenseTypeReportResponse, and related models for handling expense data.
- Introduced Skeleton loaders for expense status and charts for better UI experience during data loading.
- Updated DashboardScreen to include the new ExpenseByStatusWidget and ensure proper integration with existing components.
2025-11-01 17:29:16 +05:30
d15d9f22df Refactor theme editor widget and dashboard screen layout; enhance user document screen with improved search, filter, and document management features 2025-10-31 14:57:29 +05:30
3c89b4ddbb feat: Implement color theme persistence and toggle functionality in ThemeCustomizer and ThemeEditorWidget 2025-10-31 10:54:56 +05:30
d208648350 feat: Add timestamp functionality to images in attendance and expense controllers
- Implemented TimestampImageHelper to add timestamps to images.
- Updated AttendanceController to apply timestamps when capturing images.
- Enhanced AddExpenseController to process images with timestamps.
- Modified ReportTaskActionController and ReportTaskController to include timestamping for images.
- Updated UI components to show loading indicators while processing images.
- Refactored image picking logic to handle timestamping and loading states.
2025-10-30 18:03:54 +05:30
16a2e1e53a Merge pull request 'Feature_Theme_Chnage_Main' (#76) from Feature_Theme_Chnage_Main into main
Reviewed-on: #76
2025-10-30 05:26:05 +00:00
15b6dbd374 refactor: change default colorTheme from purple to red in ThemeCustomizer 2025-10-29 11:46:18 +05:30
7007a86ac7 Refactor button color references to use 'primary' instead of 'buttonColor' across multiple files
- Updated AttendanceButtonHelper to rename getButtonColor to getprimary.
- Changed button color references in AttendanceActionButton, ReusableListCard, UserDocumentFilterBottomSheet, and various auth screens to use contentTheme.primary.
- Adjusted color references in employee detail, expense, and directory views to align with the new primary color scheme.
- Removed unused ThemeOption class in UserProfileBar and updated color references accordingly.
- Ensured consistency in color usage for buttons and icons throughout the application.
2025-10-29 11:29:11 +05:30
04da062f4f Refactor UI components to use contentTheme colors
- Updated various screens to replace hardcoded color values with contentTheme.buttonColor for consistency.
- Changed icons in NotesView, UserDocumentsPage, EmployeeDetailPage, EmployeesScreen, ExpenseDetailScreen, and others to use updated icon styles.
- Refactored OfflineScreen and TenantSelectionScreen to utilize new wave background widget.
- Introduced ThemeEditorWidget in UserProfileBar for dynamic theme adjustments.
- Enhanced logout confirmation dialog styling to align with new theme colors.
2025-10-28 17:38:19 +05:30
97b45ebd91 chore: remove flutter_quill and related dependencies from pubspec.yaml 2025-10-27 15:21:46 +05:30
a245670e0a refactor: simplify comment and note handling by removing unused dependencies and consolidating logic 2025-10-27 15:20:17 +05:30
038b33e3b8 icon name changed to badge_alert 2025-10-24 12:48:23 +05:30
83218166ba revert 7fb5a5217aee404e3c940f0729086460d9bd1c4c
revert fixed initial loging
2025-10-11 08:49:37 +00:00
26611d3650 reduced snaxkbar time 2025-10-11 14:16:38 +05:30
7fb5a5217a fixed initial loging 2025-10-11 11:42:40 +05:30
706881d08d Merge pull request 'Issue_10_10_2025' (#75) from Issue_10_10_2025 into main
Reviewed-on: #75
2025-10-11 04:57:46 +00:00
e8acfe10d9 enhanced the restore button logic 2025-10-10 20:01:16 +05:30
16e2f5a4f3 enhanced desabled cards 2025-10-10 19:52:12 +05:30
cd92d4d309 added multiple bucket assignment 2025-10-10 19:46:17 +05:30
5dc2db0a8b fixed the issues 2025-10-10 19:41:13 +05:30
bb5fdb27b2 fixed delete and ad comment not updating properly in contact detailsnotes 2025-10-10 10:10:46 +05:30
acb203848e fixed the bugs 2025-10-09 18:09:54 +05:30
e6238ca5b0 Merge pull request 'Tenant_Selection_Issue_Fixed' (#74) from Tenant_Selection_Issue_Fixed into main
Reviewed-on: #74
2025-10-09 09:28:43 +00:00
d5a8d08e63 added splash screen 2025-10-08 17:33:20 +05:30
041b62ca2f fixed the tennt selection process 2025-10-08 16:07:26 +05:30
d1d48b1a74 fixed auto tenant selection 2025-10-08 11:41:38 +05:30
7e75431feb fixed permissions loading issue on employee screen 2025-10-08 11:20:50 +05:30
45bc492683 fixed tenant issue 2025-10-08 11:06:39 +05:30
26675388dd corrected the prefield data while editing employee 2025-10-06 12:27:43 +05:30
312 changed files with 35922 additions and 11203 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
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/employees/employee_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/directory/directory_controller.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/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/controller/directory/directory_controller.dart';
class ManageBucketController extends GetxController {
RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;

View File

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

View File

@ -1,7 +1,7 @@
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/document/document_details_model.dart';
import 'package:marco/model/document/document_version_model.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

View File

@ -1,10 +1,10 @@
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/document/master_document_type_model.dart';
import 'package:marco/model/document/master_document_tags.dart';
import 'package:marco/helpers/widgets/my_snackbar.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

View File

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

View File

@ -1,8 +1,8 @@
import 'dart:async';
import 'package:get/get.dart';
import 'package:marco/model/dynamicMenu/dynamic_menu_model.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.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

View File

@ -1,11 +1,11 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/my_controller.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_snackbar.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';

View File

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

View File

@ -1,91 +1,60 @@
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/attendance_model.dart';
import 'package:marco/model/project_model.dart';
import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/model/employees/employee_details_model.dart';
import 'package:marco/controller/project_controller.dart';
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 {
List<AttendanceModel> attendances = [];
List<ProjectModel> projects = [];
String? selectedProjectId;
List<EmployeeDetailsModel> employeeDetails = [];
RxBool isAllEmployeeSelected = false.obs;
/// Data lists
RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
RxBool isLoading = false.obs;
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.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();
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']);
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), // pass orgId to API
() => 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();
logSafe(
"No Employee data found or API call failed",
level: LogLevel.warning,
);
selectedEmployeeIds.clear();
isAllEmployeeSelected.value = false;
logSafe("No Employee data found or API call failed",
level: LogLevel.warning);
},
);
@ -93,28 +62,7 @@ class EmployeesScreenController extends GetxController {
update(['employee_screen_controller']);
}
Future<void> fetchEmployeesByProject(String projectId,
{String? organizationId}) async {
if (projectId.isEmpty) return;
isLoading.value = true;
await _handleApiCall(
() => ApiService.getAllEmployeesByProject(projectId,
organizationId: organizationId),
onSuccess: (data) {
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
for (var emp in employees) {
uploadingStates[emp.id] = false.obs;
}
},
onEmpty: () => employees.clear(),
);
isLoading.value = false;
update(['employee_screen_controller']);
}
/// 🔹 Fetch details for a specific employee
Future<void> fetchEmployeeDetails(String? employeeId) async {
if (employeeId == null || employeeId.isEmpty) return;
@ -124,31 +72,80 @@ class EmployeesScreenController extends GetxController {
() => ApiService.getEmployeeDetails(employeeId),
onSuccess: (data) {
selectedEmployeeDetails.value = EmployeeDetailsModel.fromJson(data);
logSafe(
"Employee details loaded for $employeeId",
level: LogLevel.info,
);
logSafe("Employee details loaded for $employeeId",
level: LogLevel.info);
},
onEmpty: () {
selectedEmployeeDetails.value = null;
logSafe(
"No employee details found for $employeeId",
level: LogLevel.warning,
);
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,
);
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,
@ -171,6 +168,7 @@ class EmployeesScreenController extends GetxController {
}
}
/// 🔹 Generic handler for single-object API responses
Future<void> _handleSingleApiCall(
Future<Map<String, dynamic>?> Function() apiCall, {
required Function(Map<String, dynamic>) onSuccess,

View File

@ -1,7 +1,7 @@
import 'dart:async';
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 {
Timer? countdownTimer;

View File

@ -1,5 +1,5 @@
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 {
void goToDashboardScreen() {

View File

@ -1,5 +1,5 @@
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 {
void goToDashboardScreen() {

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import 'package:marco/controller/my_controller.dart';
import 'package:marco/helpers/widgets/my_text_utils.dart';
import 'package:on_field_work/controller/my_controller.dart';
import 'package:on_field_work/helpers/widgets/my_text_utils.dart';
class FaqsController extends MyController {
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 {
bool isMonth = false;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
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/helpers/theme/theme_customizer.dart';
import 'package:marco/model/project_model.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/theme/theme_customizer.dart';
import 'package:on_field_work/model/project_model.dart';
class LayoutController extends GetxController {
// Theme Customization
@ -55,7 +55,7 @@ class LayoutController extends GetxController {
isLoadingProjects.value = true;
try {
final response = await ApiService.getProjects();
final response = await ApiService.getGlobalProjects();
if (response != null && response.isNotEmpty) {
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:marco/helpers/theme/theme_customizer.dart';
import 'package:on_field_work/helpers/theme/theme_customizer.dart';
abstract class MyController extends GetxController {
@override

View File

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

View File

@ -1,8 +1,8 @@
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/global_project_model.dart';
import 'package:marco/helpers/services/storage/local_storage.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/global_project_model.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
class ProjectController extends GetxController {
RxList<GlobalProjectModel> projects = <GlobalProjectModel>[].obs;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import 'package:get/get.dart';
import 'package: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/dailyTaskPlanning/master_work_category_model.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/dailyTaskPlanning/master_work_category_model.dart';
class AddTaskController extends GetxController {
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;

View File

@ -1,9 +1,10 @@
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/dailyTaskPlanning/daily_task_model.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 = [];
@ -12,6 +13,10 @@ class DailyTaskController extends GetxController {
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;
@ -23,17 +28,28 @@ class DailyTaskController extends GetxController {
}
}
RxSet<String> selectedBuildings = <String>{}.obs;
RxSet<String> selectedFloors = <String>{}.obs;
RxSet<String> selectedActivities = <String>{}.obs;
RxSet<String> selectedServices = <String>{}.obs;
RxBool isFilterLoading = false.obs;
RxBool isLoading = true.obs;
RxBool 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() {
@ -51,9 +67,41 @@ class DailyTaskController extends GetxController {
);
}
void _initializeRxDates() {
startDateTaskRx.value =
startDateTask ?? DateTime.now().subtract(const Duration(days: 7));
endDateTaskRx.value = endDateTask ?? DateTime.now();
}
void clearTaskFilters() {
selectedBuildings.clear();
selectedFloors.clear();
selectedActivities.clear();
selectedServices.clear();
startDateTask = null;
endDateTask = null;
// reset Rx dates as well
startDateTaskRx.value = DateTime.now().subtract(const Duration(days: 7));
endDateTaskRx.value = DateTime.now();
update();
}
void updateDateRange(DateTime? start, DateTime? end) {
if (start != null && end != null) {
startDateTask = start;
endDateTask = end;
startDateTaskRx.value = start;
endDateTaskRx.value = end;
update();
}
}
Future<void> fetchTaskData(
String projectId, {
List<String>? serviceIds,
int pageNumber = 1,
int pageSize = 20,
bool isLoadMore = false,
@ -68,22 +116,42 @@ class DailyTaskController extends GetxController {
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,
dateFrom: startDateTask,
dateTo: endDateTask,
serviceIds: serviceIds,
filter: filter,
pageNumber: pageNumber,
pageSize: pageSize,
);
if (response != null && response.isNotEmpty) {
for (var taskJson in response) {
final task = TaskModel.fromJson(taskJson);
if (!isLoadMore) {
groupedDailyTasks.clear();
}
for (var task in response) {
final assignmentDateKey =
task.assignmentDate.toIso8601String().split('T')[0];
groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []).add(task);
// Initialize list if not present
groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []);
// Only add task if it doesn't already exist (avoid duplicates)
if (!groupedDailyTasks[assignmentDateKey]!
.any((t) => t.id == task.id)) {
groupedDailyTasks[assignmentDateKey]!.add(task);
}
}
dailyTasks = groupedDailyTasks.values.expand((list) => list).toList();
currentPage = pageNumber;
} else {
@ -96,6 +164,32 @@ class DailyTaskController extends GetxController {
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,
@ -119,12 +213,15 @@ class DailyTaskController extends GetxController {
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,
);
// Add null check before calling fetchTaskData
final projectId = controller.selectedProjectId;
if (projectId != null && projectId.isNotEmpty) {
await controller.fetchTaskData(projectId);
@ -138,9 +235,7 @@ class DailyTaskController extends GetxController {
required String projectId,
required String taskAllocationId,
}) async {
// re-fetch tasks
await fetchTaskData(projectId);
update(); // rebuilds UI
update();
}
}

View File

@ -1,41 +1,40 @@
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/dailyTaskPlanning/daily_task_planning_model.dart';
import 'package:marco/model/employees/employee_model.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 = [];
List<EmployeeModel> employees = [];
List<TaskPlanningDetailsModel> dailyTasks = [];
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
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 isLoading = false.obs;
RxBool isFetchingTasks = true.obs;
RxBool isFetchingProjects = true.obs;
RxBool isFetchingEmployees = true.obs;
/// New: track per-building loading and loaded state for lazy infra loading
RxMap<String, RxBool> buildingLoadingStates = <String, RxBool>{}.obs;
final Set<String> buildingsWithDetails = <String>{};
@override
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 (value == null || value.trim().isEmpty) return 'This field is required';
if (fieldType == "target" && int.tryParse(value.trim()) == null) {
return 'Please enter a valid number';
}
@ -46,9 +45,8 @@ class DailyTaskPlanningController extends GetxController {
}
void updateSelectedEmployees() {
final selected =
selectedEmployees.value =
employees.where((e) => uploadingStates[e.id]?.value == true).toList();
selectedEmployees.value = selected;
logSafe("Updated selected employees", level: LogLevel.debug);
}
@ -75,6 +73,8 @@ class DailyTaskPlanningController extends GetxController {
required String description,
required List<String> taskTeam,
DateTime? assignmentDate,
String? organizationId,
String? serviceId,
}) async {
isAssigningTask.value = true;
logSafe("Starting assign task...", level: LogLevel.info);
@ -85,6 +85,8 @@ class DailyTaskPlanningController extends GetxController {
description: description,
taskTeam: taskTeam,
assignmentDate: assignmentDate,
organizationId: organizationId,
serviceId: serviceId,
);
isAssigningTask.value = false;
@ -108,68 +110,39 @@ class DailyTaskPlanningController extends GetxController {
}
}
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;
}
}
/// Fetch Infra details and then tasks per work area
/// Fetch buildings list only (no deep area/workItem calls) for initial load.
Future<void> fetchTaskData(String? projectId, {String? serviceId}) async {
if (projectId == null) {
logSafe("Project ID is null", level: LogLevel.warning);
return;
}
if (projectId == null) return;
isLoading.value = true;
isFetchingTasks.value = true;
try {
// Fetch infra details
final infraResponse = await ApiService.getInfraDetails(projectId);
final infraResponse = await ApiService.getInfraDetails(
projectId,
serviceId: serviceId,
);
final infraData = infraResponse?['data'] as List<dynamic>?;
if (infraData == null || infraData.isEmpty) {
logSafe("No infra data found for project $projectId",
level: LogLevel.warning);
dailyTasks = [];
return;
}
// Map infra to dailyTasks structure
dailyTasks = infraData.map((buildingJson) {
// 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: (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 fill after tasks API
);
}).toList(),
);
}).toList(),
floors: [],
plannedWork: (buildingJson['plannedWork'] as num?)?.toDouble() ?? 0,
completedWork:
(buildingJson['completedWork'] as num?)?.toDouble() ?? 0,
);
return TaskPlanningDetailsModel(
@ -184,95 +157,210 @@ class DailyTaskPlanningController extends GetxController {
);
}).toList();
// Fetch tasks for each work area, passing serviceId only if selected
await Future.wait(dailyTasks
.expand((task) => task.buildings)
.expand((b) => b.floors)
.expand((f) => f.workAreas)
.map((area) async {
try {
final taskResponse = await ApiService.getWorkItemsByWorkArea(
area.id,
// serviceId: serviceId, // <-- only pass if not null
);
final taskData = taskResponse?['data'] as List<dynamic>? ?? [];
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();
}
}
area.workItems.addAll(taskData.map((taskJson) {
return 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,
),
);
}));
/// 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);
}
}));
logSafe("Fetched infra and tasks for project $projectId",
level: LogLevel.info);
// 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 daily task data",
logSafe("Error fetching infra for building $buildingId",
level: LogLevel.error, error: e, stackTrace: stack);
} finally {
isLoading.value = false;
buildingLoadingStates.putIfAbsent(buildingId, () => false.obs);
buildingLoadingStates[buildingId]!.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;
}
Future<void> fetchEmployeesByProjectService({
required String projectId,
String? serviceId,
String? organizationId,
}) async {
isFetchingEmployees.value = true;
isLoading.value = true;
try {
final response = await ApiService.getAllEmployeesByProject(projectId);
final response = await ApiService.getEmployeesByProjectService(
projectId,
serviceId: serviceId ?? '',
organizationId: organizationId ?? '',
);
if (response != null && response.isNotEmpty) {
employees =
response.map((json) => EmployeeModel.fromJson(json)).toList();
for (var emp in employees) {
uploadingStates[emp.id] = false.obs;
employees
.assignAll(response.map((json) => EmployeeModel.fromJson(json)));
if (serviceId == null && organizationId == null) {
allEmployeesCache = List.from(employees);
}
logSafe(
"Employees fetched: ${employees.length} for project $projectId",
level: LogLevel.info,
);
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 = [];
employees.clear();
uploadingStates.clear();
selectedEmployees.clear();
logSafe(
"No employees found for project $projectId",
serviceId != null || organizationId != null
? "Filtered employees empty"
: "No employees found",
level: LogLevel.warning,
);
}
} catch (e, stack) {
logSafe(
"Error fetching employees for project $projectId",
level: LogLevel.error,
error: e,
stackTrace: 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 {
isLoading.value = false;
isFetchingEmployees.value = false;
update();
}
}

View File

@ -4,15 +4,16 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.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:marco/controller/task_Planning/daily_task_Planning_controller.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_image_compressor.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/dailyTaskPlanning/work_status_model.dart';
import 'package:on_field_work/controller/my_controller.dart';
import 'package:on_field_work/controller/task_Planning/daily_task_Planning_controller.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_image_compressor.dart';
import 'package:on_field_work/helpers/widgets/my_snackbar.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 }
@ -32,9 +33,11 @@ class ReportTaskActionController extends MyController {
final Rxn<Map<String, dynamic>> selectedTask = Rxn<Map<String, dynamic>>();
final RxString selectedWorkStatusName = ''.obs;
final RxBool isPickingImage = false.obs;
final MyFormValidator basicValidator = MyFormValidator();
final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController());
final DailyTaskPlanningController taskController =
Get.put(DailyTaskPlanningController());
final ImagePicker _picker = ImagePicker();
final assignedDateController = TextEditingController();
@ -83,18 +86,31 @@ class ReportTaskActionController extends MyController {
void _initializeFormFields() {
basicValidator
..addField('assigned_date', label: "Assigned Date", controller: assignedDateController)
..addField('work_area', label: "Work Area", controller: workAreaController)
..addField('assigned_date',
label: "Assigned Date", controller: assignedDateController)
..addField('work_area',
label: "Work Area", controller: workAreaController)
..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('assigned', label: "Assigned", controller: assignedController)
..addField('completed_work', label: "Completed Work", required: true, controller: completedWorkController)
..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)
..addField('approved_task', label: "Approved Task", required: true, controller: approvedTaskController);
..addField('completed_work',
label: "Completed Work",
required: true,
controller: completedWorkController)
..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)
..addField('approved_task',
label: "Approved Task",
required: true,
controller: approvedTaskController);
}
Future<bool> approveTask({
@ -108,7 +124,8 @@ class ReportTaskActionController extends MyController {
if (projectId.isEmpty || reportActionId.isEmpty) {
_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;
}
@ -117,13 +134,15 @@ class ReportTaskActionController extends MyController {
if (approvedTaskInt == null) {
_showError("Invalid approved task count.");
logSafe("Invalid approvedTaskCount: $approvedTaskCount", level: LogLevel.warning);
logSafe("Invalid approvedTaskCount: $approvedTaskCount",
level: LogLevel.warning);
return false;
}
if (completedWorkInt != null && approvedTaskInt > completedWorkInt) {
_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;
}
@ -159,7 +178,8 @@ class ReportTaskActionController extends MyController {
return false;
}
} 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.");
return false;
} finally {
@ -207,7 +227,8 @@ class ReportTaskActionController extends MyController {
_showError("Failed to comment task.");
}
} 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.");
} finally {
isLoading.value = false;
@ -224,7 +245,8 @@ class ReportTaskActionController extends MyController {
workStatus.assignAll(model.data);
logSafe("Fetched ${model.data.length} work statuses");
} 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;
@ -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();
}
@ -267,23 +290,40 @@ class ReportTaskActionController extends MyController {
}
Future<void> pickImages({required bool fromCamera}) async {
logSafe("Opening image picker...");
if (fromCamera) {
final pickedFile = await _picker.pickImage(source: ImageSource.camera, imageQuality: 75);
if (pickedFile != null) {
selectedImages.add(File(pickedFile.path));
logSafe("Image added from camera: ${pickedFile.path}", );
try {
isPickingImage.value = true; // start loading
logSafe("Opening image picker...");
if (fromCamera) {
final pickedFile = await _picker.pickImage(
source: ImageSource.camera,
imageQuality: 75,
);
if (pickedFile != null) {
final timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: File(pickedFile.path),
);
selectedImages.add(timestampedFile);
logSafe("Image added from camera with timestamp: ${pickedFile.path}");
}
} else {
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path)));
logSafe("${pickedFiles.length} images added from gallery.");
}
} else {
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path)));
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) {
if (index >= 0 && index < selectedImages.length) {
logSafe("Removing image at index $index", );
logSafe(
"Removing image at index $index",
);
selectedImages.removeAt(index);
}
}

View File

@ -1,20 +1,22 @@
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:on_field_work/controller/my_controller.dart';
import 'package:on_field_work/helpers/widgets/my_form_validator.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.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/controller/task_Planning/daily_task_Planning_controller.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
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 }
final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController());
final DailyTaskPlanningController taskController =
Get.put(DailyTaskPlanningController());
final ImagePicker _picker = ImagePicker();
class ReportTaskController extends MyController {
@ -23,6 +25,7 @@ class ReportTaskController extends MyController {
RxBool isLoading = false.obs;
Rx<ApiStatus> reportStatus = ApiStatus.idle.obs;
Rx<ApiStatus> commentStatus = ApiStatus.idle.obs;
final RxBool isPickingImage = false.obs;
RxList<File> selectedImages = <File>[].obs;
@ -43,17 +46,27 @@ class ReportTaskController extends MyController {
super.onInit();
logSafe("Initializing ReportTaskController...");
basicValidator
..addField('assigned_date', label: "Assigned Date", controller: assignedDateController)
..addField('work_area', label: "Work Area", controller: workAreaController)
..addField('assigned_date',
label: "Assigned Date", controller: assignedDateController)
..addField('work_area',
label: "Work Area", controller: workAreaController)
..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('assigned', label: "Assigned", controller: assignedController)
..addField('completed_work', label: "Completed Work", required: true, controller: completedWorkController)
..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);
..addField('completed_work',
label: "Completed Work",
required: true,
controller: completedWorkController)
..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.");
}
@ -83,9 +96,13 @@ class ReportTaskController extends MyController {
required DateTime reportedDate,
List<File>? images,
}) async {
logSafe("Reporting task for projectId", );
logSafe(
"Reporting task for projectId",
);
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.");
return false;
}
@ -121,7 +138,8 @@ class ReportTaskController extends MyController {
return false;
}
} 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;
_showError("An error occurred while reporting the task.");
return false;
@ -138,7 +156,9 @@ class ReportTaskController extends MyController {
required String comment,
List<File>? images,
}) async {
logSafe("Submitting comment for project", );
logSafe(
"Submitting comment for project",
);
final commentField = commentController.text.trim();
if (commentField.isEmpty) {
@ -166,14 +186,16 @@ class ReportTaskController extends MyController {
_showError("Failed to comment task.");
}
} 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.");
} finally {
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;
logSafe("Preparing images for $context upload...");
@ -191,7 +213,8 @@ class ReportTaskController extends MyController {
"description": "Image uploaded for $context",
};
} 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;
}
}));
@ -212,18 +235,31 @@ class ReportTaskController extends MyController {
Future<void> pickImages({required bool fromCamera}) async {
try {
isPickingImage.value = true; // Start loading
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) {
selectedImages.add(File(pickedFile.path));
// Only camera images get timestamp
final timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: File(pickedFile.path),
);
selectedImages.add(timestampedFile);
}
} else {
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
// Gallery images added as-is without timestamp
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path)));
}
logSafe("Images picked: ${selectedImages.length}", );
logSafe("Images picked: ${selectedImages.length}");
} catch (e) {
logSafe("Error picking images", level: LogLevel.warning, error: e);
} finally {
isPickingImage.value = false; // Stop loading
}
}

View File

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

View File

@ -1,7 +1,7 @@
import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/attendance/organization_per_project_list_model.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

View File

@ -1,7 +1,7 @@
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/model/tenant/tenant_services_model.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 = [];

View File

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

View File

@ -1,7 +1,7 @@
import 'dart:async';
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';
class CarouselsController extends MyController {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import 'package:marco/controller/my_controller.dart';
import 'package:marco/helpers/widgets/my_text_utils.dart';
import 'package:on_field_work/controller/my_controller.dart';
import 'package:on_field_work/helpers/widgets/my_text_utils.dart';
import 'package:flutter/material.dart';
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';
class ToastMessageController extends MyController {

View File

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

View File

@ -1,5 +1,5 @@
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 {
Color get toColor {

View File

@ -2,19 +2,53 @@ class ApiEndpoints {
static const String baseUrl = "https://stageapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
// static const String baseUrl = "https://mapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.onfieldwork.com/api";
static const String 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 getGlobalProjects = "/project/list/basic";
static const String getTodaysAttendance = "/attendance/project/team";
static const String getAttendanceForDashboard = "/dashboard/get/attendance/employee/:projectId";
static const String getAttendanceLogs = "/attendance/project/log";
static const String getAttendanceLogView = "/attendance/log/attendance";
static const String getRegularizationLogs = "/attendance/regularize";
@ -22,6 +56,7 @@ class ApiEndpoints {
// Employee Screen API Endpoints
static const String getAllEmployeesByProject = "/employee/list";
static const String getAllEmployeesByOrganization = "/project/get/task/team";
static const String getAllEmployees = "/employee/list";
static const String getEmployeesWithoutPermission = "/employee/basic";
static const String getRoles = "/roles/jobrole";
@ -41,6 +76,7 @@ class ApiEndpoints {
static const String approveReportAction = "/task/approve";
static const String assignTask = "/project/task";
static const String getmasterWorkCategories = "/Master/work-categories";
static const String getDailyTaskProjectProgressFilter = "/task/filter";
////// Directory Module API Endpoints ///////
static const String getDirectoryContacts = "/directory";
@ -52,6 +88,8 @@ class ApiEndpoints {
static const String getDirectoryOrganization = "/directory/organization";
static const String createContact = "/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 updateDirectoryNotes = "/directory/note";
static const String createBucket = "/directory/bucket";
@ -66,7 +104,7 @@ class ApiEndpoints {
static const String editExpense = "/Expense/edit";
static const String getMasterPaymentModes = "/master/payment-modes";
static const String getMasterExpenseStatus = "/master/expenses-status";
static const String getMasterExpenseTypes = "/master/expenses-types";
static const String getMasterExpenseCategory = "/master/expenses-categories";
static const String updateExpenseStatus = "/expense/action";
static const String deleteExpense = "/expense/delete";
@ -93,5 +131,40 @@ class ApiEndpoints {
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,17 +1,13 @@
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:url_strategy/url_strategy.dart';
import 'package:firebase_core/firebase_core.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/services/app_logger.dart';
import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart';
import 'package:marco/helpers/services/device_info_service.dart';
import 'package:marco/helpers/theme/theme_customizer.dart';
import 'package:marco/helpers/theme/app_theme.dart';
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 {
try {
@ -26,7 +22,6 @@ Future<void> initializeApp() async {
await _setupDeviceInfo();
await _handleAuthTokens();
await _setupTheme();
await _setupControllers();
await _setupFirebaseMessaging();
_finalizeAppStyle();
@ -43,6 +38,19 @@ Future<void> initializeApp() async {
}
}
Future<void> _handleAuthTokens() async {
final refreshToken = await LocalStorage.getRefreshToken();
if (refreshToken?.isNotEmpty ?? false) {
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
final success = await AuthService.refreshToken();
if (!success) {
logSafe("⚠️ Refresh token invalid or expired. User must login again.");
}
} else {
logSafe("❌ No refresh token found. Skipping refresh.");
}
}
Future<void> _setupUI() async {
setPathUrlStrategy();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@ -69,50 +77,11 @@ Future<void> _setupDeviceInfo() async {
logSafe("📱 Device Info: ${deviceInfoService.deviceData}");
}
Future<void> _handleAuthTokens() async {
final refreshToken = await LocalStorage.getRefreshToken();
if (refreshToken?.isNotEmpty ?? false) {
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
final success = await AuthService.refreshToken();
if (!success) {
logSafe(
"⚠️ Refresh token invalid or expired. Skipping controller injection.");
}
} else {
logSafe("❌ No refresh token found. Skipping refresh.");
}
}
Future<void> _setupTheme() async {
await ThemeCustomizer.init();
logSafe("💡 Theme customizer initialized.");
}
Future<void> _setupControllers() async {
final token = LocalStorage.getString('jwt_token');
if (token?.isEmpty ?? true) {
logSafe("⚠️ No valid JWT token found. Skipping controller initialization.");
return;
}
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
logSafe("💡 PermissionController injected.");
}
if (!Get.isRegistered<ProjectController>()) {
Get.put(ProjectController(), permanent: true);
logSafe("💡 ProjectController injected as permanent.");
}
await Future.wait([
Get.find<PermissionController>().loadData(token!),
Get.find<ProjectController>().fetchProjects(),
]);
}
// Commented out Firebase Messaging setup
Future<void> _setupFirebaseMessaging() async {
await FirebaseNotificationService().initialize();
logSafe("💡 Firebase Messaging initialized.");

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import 'dart:convert';
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:get/get_utils/src/extensions/string_extensions.dart';
import 'package:shared_preferences/shared_preferences.dart';

View File

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

View File

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

View File

@ -2,13 +2,13 @@ import 'dart:convert';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:marco/controller/project_controller.dart';
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:marco/model/employees/employee_info.dart';
import 'package:marco/model/user_permission.dart';
import 'package:marco/model/dynamicMenu/dynamic_menu_model.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 {
static const String _loggedInUserKey = "user";

View File

@ -1,13 +1,13 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:get/get.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:marco/helpers/services/api_endpoints.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/model/tenant/tenant_list_model.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 {
@ -63,29 +63,39 @@ class TenantService implements ITenantService {
{bool hasRetried = false}) async {
try {
final headers = await _authorizedHeaders();
logSafe("➡️ GET $_baseUrl/auth/get/user/tenants\nHeaders: $headers",
level: LogLevel.info);
final response = await http
.get(Uri.parse("$_baseUrl/auth/get/user/tenants"), headers: headers);
final data = jsonDecode(response.body);
final response = await http.get(
Uri.parse("$_baseUrl/auth/get/user/tenants"),
headers: headers,
);
logSafe(
"⬅️ Response: ${jsonEncode(data)} [Status: ${response.statusCode}]",
level: LogLevel.info);
if (response.statusCode == 200 && data['success'] == true) {
logSafe("✅ Tenants fetched successfully.");
return List<Map<String, dynamic>>.from(data['data']);
// 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) {
logSafe("⚠️ Unauthorized while fetching tenants. Refreshing token...",
level: LogLevel.warning);
final refreshed = await AuthService.refreshToken();
if (refreshed) return getTenants(hasRetried: true);
logSafe("❌ Token refresh failed while fetching tenants.",
level: LogLevel.error);
return null;
}
@ -129,6 +139,17 @@ class TenantService implements ITenantService {
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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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