Compare commits

..

204 Commits

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

View File

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

View File

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

View File

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

View File

@ -1,37 +1,56 @@
This XML file does not appear to have any style information associated with it. The document tree is shown below.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application android:label="Marco_Stage" android:name="${applicationName}" android:icon="@mipmap/ic_launcher">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:taskAffinity="" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
<application
android:label="Marco"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme"/>
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="high_importance_channel"/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data android:name="flutterEmbedding" android:value="2"/>
</application>
<!-- Required to query activities that can process text, see:
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="high_importance_channel"/>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

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

View File

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

View File

@ -18,10 +18,9 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.2.1" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
id "com.android.application" version "8.6.0" apply false
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
id("com.google.gms.google-services") version "4.4.2" apply false
}
include ":app"

View File

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

55
build_release.sh Normal file
View File

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

3
devtools_options.yaml Normal file
View File

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

View File

@ -368,7 +368,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco;
PRODUCT_BUNDLE_IDENTIFIER = com.marco.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.example.marco.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.marco.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.example.marco.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.marco.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.example.marco.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.marco.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.example.marco;
PRODUCT_BUNDLE_IDENTIFIER = com.marco.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.example.marco;
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;

View File

@ -0,0 +1,426 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_image_compressor.dart';
import 'package:marco/model/attendance/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';
class AttendanceController extends GetxController {
// Data models
List<AttendanceModel> attendances = [];
List<ProjectModel> projects = [];
List<EmployeeModel> employees = [];
List<AttendanceLogModel> attendanceLogs = [];
List<RegularizationLogModel> regularizationLogs = [];
List<AttendanceLogViewModel> attendenceLogsView = [];
// ------------------ Organizations ------------------
List<Organization> organizations = [];
Organization? selectedOrganization;
final isLoadingOrganizations = false.obs;
// States
String selectedTab = 'todaysAttendance';
DateTime? startDateAttendance;
DateTime? endDateAttendance;
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;
@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() {
_setDefaultDateRange();
}
void _setDefaultDateRange() {
final today = DateTime.now();
startDateAttendance = today.subtract(const Duration(days: 7));
endDateAttendance = today.subtract(const Duration(days: 1));
logSafe(
"Default date range set: $startDateAttendance to $endDateAttendance");
}
// ------------------ Project & Employee ------------------
/// Called when a notification says attendance has been updated
Future<void> refreshDataFromNotification({String? projectId}) async {
projectId ??= Get.find<ProjectController>().selectedProject?.id;
if (projectId == null) {
logSafe("No project selected for attendance refresh from notification",
level: LogLevel.warning);
return;
}
await fetchProjectData(projectId);
logSafe(
"Attendance data refreshed from notification for project $projectId");
}
// 🔍 Search query
final searchQuery = ''.obs;
// Computed filtered employees
List<EmployeeModel> get filteredEmployees {
if (searchQuery.value.isEmpty) return employees;
return employees
.where((e) =>
e.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
// Computed filtered logs
List<AttendanceLogModel> get filteredLogs {
if (searchQuery.value.isEmpty) return attendanceLogs;
return attendanceLogs
.where((log) =>
(log.name).toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
// Computed filtered regularization logs
List<RegularizationLogModel> get filteredRegularizationLogs {
if (searchQuery.value.isEmpty) return regularizationLogs;
return regularizationLogs
.where((log) =>
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
Future<void> fetchTodaysAttendance(String? projectId) async {
if (projectId == null) return;
isLoadingEmployees.value = true;
final response = await ApiService.getTodaysAttendance(
projectId,
organizationId: selectedOrganization?.id,
);
if (response != null) {
employees = response.map((e) => EmployeeModel.fromJson(e)).toList();
for (var emp in employees) {
uploadingStates[emp.id] = false.obs;
}
logSafe("Employees fetched: ${employees.length} for project $projectId");
} else {
logSafe("Failed to fetch employees for project $projectId",
level: LogLevel.error);
}
isLoadingEmployees.value = false;
update();
}
Future<void> fetchOrganizations(String projectId) async {
isLoadingOrganizations.value = true;
final response = await ApiService.getAssignedOrganizations(projectId);
if (response != null) {
organizations = response.data;
logSafe("Organizations fetched: ${organizations.length}");
} else {
logSafe("Failed to fetch organizations for project $projectId",
level: LogLevel.error);
}
isLoadingOrganizations.value = false;
update();
}
// ------------------ Attendance Capture ------------------
Future<bool> captureAndUploadAttendance(
String id,
String employeeId,
String projectId, {
String comment = "Marked via mobile app",
required int action,
bool imageCapture = true,
String? markTime, // still optional in controller
String? date, // new optional param
}) async {
try {
uploadingStates[employeeId]?.value = true;
XFile? image;
if (imageCapture) {
image = await ImagePicker()
.pickImage(source: ImageSource.camera, imageQuality: 80);
if (image == null) {
logSafe("Image capture cancelled.", level: LogLevel.warning);
return false;
}
final compressedBytes =
await compressImageToUnder100KB(File(image.path));
if (compressedBytes == null) {
logSafe("Image compression failed.", level: LogLevel.error);
return false;
}
final compressedFile = await saveCompressedImageToFile(compressedBytes);
image = XFile(compressedFile.path);
}
if (!await _handleLocationPermission()) return false;
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high);
final imageName = imageCapture
? ApiService.generateImageName(employeeId, employees.length + 1)
: "";
// ---------------- DATE / TIME LOGIC ----------------
final now = DateTime.now();
// 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 =
date ?? DateFormat('yyyy-MM-dd').format(effectiveDate);
// ---------------- API CALL ----------------
final result = await ApiService.uploadAttendanceImage(
id,
employeeId,
image,
position.latitude,
position.longitude,
imageName: imageName,
projectId: projectId,
comment: comment,
action: action,
imageCapture: imageCapture,
markTime: formattedMarkTime,
date: formattedDate,
);
logSafe(
"Attendance uploaded for $employeeId, action: $action, date: $formattedDate");
return result;
} catch (e, stacktrace) {
logSafe("Error uploading attendance",
level: LogLevel.error, error: e, stackTrace: stacktrace);
return false;
} finally {
uploadingStates[employeeId]?.value = false;
}
}
Future<bool> _handleLocationPermission() async {
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
logSafe('Location permissions are denied', level: LogLevel.warning);
return false;
}
}
if (permission == LocationPermission.deniedForever) {
logSafe('Location permissions are permanently denied',
level: LogLevel.error);
return false;
}
return true;
}
// ------------------ Attendance Logs ------------------
Future<void> fetchAttendanceLogs(String? projectId,
{DateTime? dateFrom, DateTime? dateTo}) async {
if (projectId == null) return;
isLoadingAttendanceLogs.value = true;
final 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}");
} else {
logSafe("Failed to fetch attendance logs for project $projectId",
level: LogLevel.error);
}
isLoadingAttendanceLogs.value = false;
update();
}
Map<String, List<AttendanceLogModel>> groupLogsByCheckInDate() {
final groupedLogs = <String, List<AttendanceLogModel>>{};
for (var logItem in attendanceLogs) {
final checkInDate = logItem.checkIn != null
? DateFormat('dd MMM yyyy').format(logItem.checkIn!)
: 'Unknown';
groupedLogs.putIfAbsent(checkInDate, () => []).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);
});
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(
projectId,
organizationId: selectedOrganization?.id,
);
if (response != null) {
regularizationLogs =
response.map((e) => RegularizationLogModel.fromJson(e)).toList();
logSafe("Regularization logs fetched: ${regularizationLogs.length}");
} else {
logSafe("Failed to fetch regularization logs for project $projectId",
level: LogLevel.error);
}
isLoadingRegularizationLogs.value = false;
update();
}
// ------------------ Attendance Log View ------------------
Future<void> fetchLogsView(String? id) async {
if (id == null) return;
isLoadingLogView.value = true;
final 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");
} else {
logSafe("Failed to fetch attendance log view for ID $id",
level: LogLevel.error);
}
isLoadingLogView.value = false;
update();
}
// ------------------ Combined Load ------------------
Future<void> loadAttendanceData(String projectId) async {
isLoading.value = true;
await fetchProjectData(projectId);
isLoading.value = false;
}
Future<void> fetchProjectData(String? projectId) async {
if (projectId == null) return;
await fetchOrganizations(projectId);
// Call APIs depending on the selected tab only
switch (selectedTab) {
case 'todaysAttendance':
await fetchTodaysAttendance(projectId);
break;
case 'attendanceLogs':
await fetchAttendanceLogs(
projectId,
dateFrom: startDateAttendance,
dateTo: endDateAttendance,
);
break;
case 'regularizationRequests':
await fetchRegularizationLogs(projectId);
break;
}
logSafe(
"Project data fetched for project ID: $projectId, tab: $selectedTab");
update();
}
// ------------------ UI Interaction ------------------
Future<void> selectDateRangeForAttendance(
BuildContext context, AttendanceController controller) async {
final today = DateTime.now();
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2022),
lastDate: today.subtract(const Duration(days: 1)),
initialDateRange: DateTimeRange(
start: startDateAttendance ?? today.subtract(const Duration(days: 7)),
end: endDateAttendance ?? today.subtract(const Duration(days: 1)),
),
);
if (picked != null) {
startDateAttendance = picked.start;
endDateAttendance = picked.end;
logSafe(
"Date range selected: $startDateAttendance to $endDateAttendance");
await controller.fetchAttendanceLogs(
Get.find<ProjectController>().selectedProject?.id,
dateFrom: picked.start,
dateTo: picked.end,
);
}
}
}

View File

@ -6,7 +6,7 @@ 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'; // <-- logging
import 'package:marco/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,58 +41,55 @@ 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();
logSafe("Attempting login for user: ${loginData['username']}", );
logSafe("Attempting login for user: ${loginData['username']}");
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();
logSafe("Login successful for user: ${loginData['username']}", );
Get.toNamed('/home');
enableRemoteLogging();
logSafe("Login successful for user: ${loginData['username']}");
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;
}
}
Future<void> _handleRememberMe() async {
if (isChecked.value) {
await LocalStorage.setToken('username', basicValidator.getController('username')!.text);
await LocalStorage.setToken('password', basicValidator.getController('password')!.text);
await LocalStorage.setToken(
'username', basicValidator.getController('username')!.text);
await LocalStorage.setToken(
'password', basicValidator.getController('password')!.text);
await LocalStorage.setBool('remember_me', true);
} else {
await LocalStorage.removeToken('username');
@ -114,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

@ -4,20 +4,25 @@ 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:marco/controller/permission_controller.dart';
import 'package:marco/controller/project_controller.dart';
class MPINController extends GetxController {
final MyFormValidator basicValidator = MyFormValidator();
final isNewUser = false.obs;
final isChangeMpin = false.obs;
final RxBool isLoading = false.obs;
final formKey = GlobalKey<FormState>();
final digitControllers = List.generate(6, (_) => TextEditingController());
final focusNodes = List.generate(6, (_) => FocusNode());
// Updated to 4-digit MPIN
final digitControllers = List.generate(4, (_) => TextEditingController());
final focusNodes = List.generate(4, (_) => FocusNode());
final retypeControllers = List.generate(4, (_) => TextEditingController());
final retypeFocusNodes = List.generate(4, (_) => FocusNode());
final retypeControllers = List.generate(6, (_) => TextEditingController());
final retypeFocusNodes = List.generate(6, (_) => FocusNode());
final RxInt failedAttempts = 0.obs;
@override
@ -28,16 +33,28 @@ class MPINController extends GetxController {
logSafe("onInit called. isNewUser: ${isNewUser.value}");
}
/// Enable Change MPIN mode
void setChangeMpinMode() {
isChangeMpin.value = true;
isNewUser.value = false;
clearFields();
clearRetypeFields();
logSafe("setChangeMpinMode activated");
}
/// Handle digit entry and focus movement
void onDigitChanged(String value, int index, {bool isRetype = false}) {
logSafe("onDigitChanged -> index: $index, value: $value, isRetype: $isRetype", );
logSafe(
"onDigitChanged -> index: $index, value: $value, isRetype: $isRetype");
final nodes = isRetype ? retypeFocusNodes : focusNodes;
if (value.isNotEmpty && index < 5) {
if (value.isNotEmpty && index < 3) {
nodes[index + 1].requestFocus();
} else if (value.isEmpty && index > 0) {
nodes[index - 1].requestFocus();
}
}
/// Submit MPIN for verification or generation
Future<void> onSubmitMPIN() async {
logSafe("onSubmitMPIN triggered");
@ -47,19 +64,19 @@ class MPINController extends GetxController {
}
final enteredMPIN = digitControllers.map((c) => c.text).join();
logSafe("Entered MPIN: $enteredMPIN", );
logSafe("Entered MPIN: $enteredMPIN");
if (enteredMPIN.length < 6) {
_showError("Please enter all 6 digits.");
if (enteredMPIN.length < 4) {
_showError("Please enter all 4 digits.");
return;
}
if (isNewUser.value) {
if (isNewUser.value || isChangeMpin.value) {
final retypeMPIN = retypeControllers.map((c) => c.text).join();
logSafe("Retyped MPIN: $retypeMPIN", );
logSafe("Retyped MPIN: $retypeMPIN");
if (retypeMPIN.length < 6) {
_showError("Please enter all 6 digits in Retype MPIN.");
if (retypeMPIN.length < 4) {
_showError("Please enter all 4 digits in Retype MPIN.");
return;
}
@ -70,19 +87,20 @@ class MPINController extends GetxController {
return;
}
logSafe("MPINs matched. Proceeding to generate MPIN.");
final bool success = await generateMPIN(mpin: enteredMPIN);
if (success) {
logSafe("MPIN generation successful.");
logSafe("MPIN generation/change successful.");
showAppSnackbar(
title: "Success",
message: "MPIN generated successfully. Please login again.",
message: isChangeMpin.value
? "MPIN changed successfully."
: "MPIN generated successfully. Please login again.",
type: SnackbarType.success,
);
await LocalStorage.logout();
} else {
logSafe("MPIN generation failed.", level: LogLevel.warning);
logSafe("MPIN generation/change failed.", level: LogLevel.warning);
clearFields();
clearRetypeFields();
}
@ -92,20 +110,25 @@ class MPINController extends GetxController {
}
}
/// Forgot MPIN
Future<void> onForgotMPIN() async {
logSafe("onForgotMPIN called");
isNewUser.value = true;
isChangeMpin.value = false;
clearFields();
clearRetypeFields();
}
/// Switch to login/enter MPIN screen
void switchToEnterMPIN() {
logSafe("switchToEnterMPIN called");
isNewUser.value = false;
isChangeMpin.value = false;
clearFields();
clearRetypeFields();
}
/// Show error snackbar
void _showError(String message) {
logSafe("ERROR: $message", level: LogLevel.error);
showAppSnackbar(
@ -115,18 +138,21 @@ class MPINController extends GetxController {
);
}
void _navigateToDashboard({String? message}) {
/// Navigate to dashboard
/// 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
void clearFields() {
logSafe("clearFields called");
for (final c in digitControllers) {
@ -135,6 +161,7 @@ class MPINController extends GetxController {
focusNodes.first.requestFocus();
}
/// Clear the retype MPIN fields
void clearRetypeFields() {
logSafe("clearRetypeFields called");
for (final c in retypeControllers) {
@ -143,6 +170,7 @@ class MPINController extends GetxController {
retypeFocusNodes.first.requestFocus();
}
/// Cleanup
@override
void onClose() {
logSafe("onClose called");
@ -161,9 +189,8 @@ class MPINController extends GetxController {
super.onClose();
}
Future<bool> generateMPIN({
required String mpin,
}) async {
/// Generate MPIN for new user/change MPIN
Future<bool> generateMPIN({required String mpin}) async {
try {
isLoading.value = true;
logSafe("generateMPIN started");
@ -177,7 +204,7 @@ class MPINController extends GetxController {
return false;
}
logSafe("Calling AuthService.generateMpin for employeeId: $employeeId", );
logSafe("Calling AuthService.generateMpin for employeeId: $employeeId");
final response = await AuthService.generateMpin(
employeeId: employeeId,
@ -187,21 +214,12 @@ class MPINController extends GetxController {
isLoading.value = false;
if (response == null) {
logSafe("MPIN generated successfully");
showAppSnackbar(
title: "Success",
message: "MPIN generated successfully. Please login again.",
type: SnackbarType.success,
);
await LocalStorage.logout();
return true;
} else {
logSafe("MPIN generation returned error: $response", level: LogLevel.warning);
logSafe("MPIN generation returned error: $response",
level: LogLevel.warning);
showAppSnackbar(
title: "MPIN Generation Failed",
title: "MPIN Operation Failed",
message: "Please check your inputs.",
type: SnackbarType.error,
);
@ -213,24 +231,22 @@ class MPINController extends GetxController {
} catch (e) {
isLoading.value = false;
logSafe("Exception in generateMPIN", level: LogLevel.error, error: e);
_showError("Failed to generate MPIN.");
_showError("Failed to process MPIN.");
return false;
}
}
/// Verify MPIN for existing user
Future<void> verifyMPIN() async {
logSafe("verifyMPIN triggered");
final enteredMPIN = digitControllers.map((c) => c.text).join();
logSafe("Entered MPIN: $enteredMPIN", );
if (enteredMPIN.length < 6) {
_showError("Please enter all 6 digits.");
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;
@ -239,9 +255,12 @@ class MPINController extends GetxController {
try {
isLoading.value = true;
final fcmToken = await FirebaseNotificationService().getFcmToken();
final response = await AuthService.verifyMpin(
mpin: enteredMPIN,
mpinToken: mpinToken,
fcmToken: fcmToken ?? '',
);
isLoading.value = false;
@ -250,15 +269,29 @@ 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", level: LogLevel.warning);
logSafe("MPIN verification failed: $errorMessage",
level: LogLevel.warning);
showAppSnackbar(
title: "Error",
message: errorMessage,
@ -270,14 +303,11 @@ 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.");
}
}
/// Increment failed attempts and warn
void onInvalidMPIN() {
failedAttempts.value++;
if (failedAttempts.value >= 3) {

View File

@ -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

@ -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,270 +0,0 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:marco/controller/my_controller.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:marco/helpers/services/app_logger.dart';
enum Gender {
male,
female,
other;
const Gender();
}
class AddEmployeeController extends MyController {
List<PlatformFile> files = [];
final MyFormValidator basicValidator = MyFormValidator();
Gender? selectedGender;
List<Map<String, dynamic>> roles = [];
String? selectedRoleId;
final List<Map<String, String>> countries = [
{"code": "+91", "name": "India"},
{"code": "+1", "name": "USA"},
{"code": "+971", "name": "UAE"},
{"code": "+44", "name": "UK"},
{"code": "+81", "name": "Japan"},
{"code": "+61", "name": "Australia"},
{"code": "+49", "name": "Germany"},
{"code": "+33", "name": "France"},
{"code": "+86", "name": "China"},
];
final Map<String, int> minDigitsPerCountry = {
"+91": 10,
"+1": 10,
"+971": 9,
"+44": 10,
"+81": 10,
"+61": 9,
"+49": 10,
"+33": 9,
"+86": 11,
};
final Map<String, int> maxDigitsPerCountry = {
"+91": 10,
"+1": 10,
"+971": 9,
"+44": 11,
"+81": 10,
"+61": 9,
"+49": 11,
"+33": 9,
"+86": 11,
};
String selectedCountryCode = "+91";
bool showOnline = true;
final List<String> categories = [];
@override
void onInit() {
super.onInit();
logSafe("Initializing AddEmployeeController...");
_initializeFields();
fetchRoles();
}
void _initializeFields() {
basicValidator.addField(
'first_name',
label: "First Name",
required: true,
controller: TextEditingController(),
);
basicValidator.addField(
'phone_number',
label: "Phone Number",
required: true,
controller: TextEditingController(),
);
basicValidator.addField(
'last_name',
label: "Last Name",
required: true,
controller: TextEditingController(),
);
logSafe("Fields initialized for first_name, phone_number, last_name.");
}
void onGenderSelected(Gender? gender) {
selectedGender = gender;
logSafe("Gender selected: ${gender?.name}");
update();
}
Future<void> fetchRoles() async {
logSafe("Fetching roles...");
try {
final result = await ApiService.getRoles();
if (result != null) {
roles = List<Map<String, dynamic>>.from(result);
logSafe("Roles fetched successfully.");
update();
} else {
logSafe("Failed to fetch roles: null result", level: LogLevel.error);
}
} catch (e, st) {
logSafe("Error fetching roles", level: LogLevel.error, error: e, stackTrace: st);
}
}
void onRoleSelected(String? roleId) {
selectedRoleId = roleId;
logSafe("Role selected: $roleId");
update();
}
Future<bool> createEmployees() async {
logSafe("Starting employee creation...");
if (selectedGender == null || selectedRoleId == null) {
logSafe("Missing gender or role.", level: LogLevel.warning);
showAppSnackbar(
title: "Missing Fields",
message: "Please select both Gender and Role.",
type: SnackbarType.warning,
);
return false;
}
final firstName = basicValidator.getController("first_name")?.text.trim();
final lastName = basicValidator.getController("last_name")?.text.trim();
final phoneNumber = basicValidator.getController("phone_number")?.text.trim();
logSafe("Creating employee", level: LogLevel.info);
try {
final response = await ApiService.createEmployee(
firstName: firstName!,
lastName: lastName!,
phoneNumber: phoneNumber!,
gender: selectedGender!.name,
jobRoleId: selectedRoleId!,
);
logSafe("Response: $response");
if (response == true) {
logSafe("Employee created successfully.");
showAppSnackbar(
title: "Success",
message: "Employee created successfully!",
type: SnackbarType.success,
);
return true;
} else {
logSafe("Failed to create employee (response false)", level: LogLevel.error);
}
} catch (e, st) {
logSafe("Error creating employee", level: LogLevel.error, error: e, stackTrace: st);
}
showAppSnackbar(
title: "Error",
message: "Failed to create employee.",
type: SnackbarType.error,
);
return false;
}
Future<bool> _checkAndRequestContactsPermission() async {
final status = await Permission.contacts.request();
if (status.isGranted) return true;
if (status.isPermanentlyDenied) {
await openAppSettings();
}
showAppSnackbar(
title: "Permission Required",
message: "Please allow Contacts permission from settings to pick a contact.",
type: SnackbarType.warning,
);
return false;
}
Future<void> pickContact(BuildContext context) async {
final permissionGranted = await _checkAndRequestContactsPermission();
if (!permissionGranted) return;
try {
final picked = await FlutterContacts.openExternalPick();
if (picked == null) return;
final contact = await FlutterContacts.getContact(picked.id, withProperties: true);
if (contact == null) {
showAppSnackbar(
title: "Error",
message: "Failed to load contact details.",
type: SnackbarType.error,
);
return;
}
if (contact.phones.isEmpty) {
showAppSnackbar(
title: "No Phone Number",
message: "Selected contact has no phone number.",
type: SnackbarType.warning,
);
return;
}
final indiaPhones = contact.phones.where((p) {
final normalized = p.number.replaceAll(RegExp(r'[^0-9+]'), '');
return normalized.startsWith('+91') || RegExp(r'^\d{10}$').hasMatch(normalized);
}).toList();
if (indiaPhones.isEmpty) {
showAppSnackbar(
title: "No Indian Number",
message: "Selected contact has no Indian (+91) phone number.",
type: SnackbarType.warning,
);
return;
}
String? selectedPhone;
if (indiaPhones.length == 1) {
selectedPhone = indiaPhones.first.number;
} else {
selectedPhone = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text("Choose an Indian number"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: indiaPhones
.map((p) => ListTile(
title: Text(p.number),
onTap: () => Navigator.of(ctx).pop(p.number),
))
.toList(),
),
),
);
if (selectedPhone == null) return;
}
final normalizedPhone = selectedPhone.replaceAll(RegExp(r'[^0-9]'), '');
final phoneWithoutCountryCode = normalizedPhone.length > 10
? normalizedPhone.substring(normalizedPhone.length - 10)
: normalizedPhone;
basicValidator.getController('phone_number')?.text = phoneWithoutCountryCode;
update();
} catch (e, st) {
logSafe("Error fetching contacts", level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar(
title: "Error",
message: "Failed to fetch contacts.",
type: SnackbarType.error,
);
}
}
}

View File

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

View File

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

View File

@ -2,16 +2,53 @@ 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';
class DashboardController extends GetxController {
// Observables
final RxList<Map<String, dynamic>> roleWiseData = <Map<String, dynamic>>[].obs;
final RxBool isLoading = false.obs;
final RxString selectedRange = '15D'.obs;
final RxBool isChartView = true.obs;
// =========================
// 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;
// Inject the ProjectController
final ProjectController projectController = Get.find<ProjectController>();
// =========================
// Project progress overview
// =========================
final RxList<ChartTaskData> projectChartData = <ChartTaskData>[].obs;
final RxString projectSelectedRange = '15D'.obs;
final RxBool projectIsChartView = true.obs;
final RxBool isProjectLoading = false.obs;
// =========================
// Projects overview
// =========================
final RxInt totalProjects = 0.obs;
final RxInt ongoingProjects = 0.obs;
final RxBool isProjectsLoading = false.obs;
// =========================
// Tasks overview
// =========================
final RxInt totalTasks = 0.obs;
final RxInt completedTasks = 0.obs;
final RxBool isTasksLoading = false.obs;
// =========================
// Teams overview
// =========================
final RxInt totalEmployees = 0.obs;
final RxInt inToday = 0.obs;
final RxBool isTeamsLoading = false.obs;
// Common ranges
final List<String> ranges = ['7D', '15D', '30D'];
// Inside your DashboardController
final ProjectController projectController =
Get.put(ProjectController(), permanent: true);
@override
void onInit() {
@ -20,88 +57,207 @@ class DashboardController extends GetxController {
logSafe(
'DashboardController initialized with project ID: ${projectController.selectedProjectId.value}',
level: LogLevel.info,
);
if (projectController.selectedProjectId.value.isNotEmpty) {
fetchRoleWiseAttendance();
}
fetchAllDashboardData();
// React to project change
ever<String>(projectController.selectedProjectId, (id) {
if (id.isNotEmpty) {
logSafe('Project changed to $id, fetching attendance', level: LogLevel.info, );
fetchRoleWiseAttendance();
}
fetchAllDashboardData();
});
// React to range change
ever(selectedRange, (_) {
fetchRoleWiseAttendance();
});
// React to range changes
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
ever(projectSelectedRange, (_) => fetchProjectProgress());
}
int get rangeDays => _getDaysFromRange(selectedRange.value);
// =========================
// Helper Methods
// =========================
int _getDaysFromRange(String range) {
switch (range) {
case '7D':
return 7;
case '15D':
return 15;
case '30D':
return 30;
case '7D':
case '3M':
return 90;
case '6M':
return 180;
default:
return 7;
}
}
void updateRange(String range) {
selectedRange.value = range;
logSafe('Selected range updated to $range', level: LogLevel.debug);
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 toggleChartView(bool isChart) {
isChartView.value = isChart;
logSafe('Chart view toggled to: $isChart', 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 fetchRoleWiseAttendance();
await fetchAllDashboardData();
}
Future<void> fetchRoleWiseAttendance() async {
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('Project ID is empty, skipping API call.', level: LogLevel.warning);
logSafe('No project selected. Skipping dashboard API calls.',
level: LogLevel.warning);
return;
}
try {
isLoading.value = true;
await Future.wait([
fetchRoleWiseAttendance(),
fetchProjectProgress(),
fetchDashboardTasks(projectId: projectId),
fetchDashboardTeams(projectId: projectId),
]);
}
// =========================
// API Calls
// =========================
Future<void> fetchRoleWiseAttendance() async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
try {
isAttendanceLoading.value = true;
final List<dynamic>? response =
await ApiService.getDashboardAttendanceOverview(projectId, rangeDays);
await ApiService.getDashboardAttendanceOverview(
projectId, getAttendanceDays());
if (response != null) {
roleWiseData.value =
response.map((e) => Map<String, dynamic>.from(e)).toList();
logSafe('Attendance overview fetched successfully.', level: LogLevel.info);
logSafe('Attendance overview fetched successfully.',
level: LogLevel.info);
} else {
roleWiseData.clear();
logSafe('Failed to fetch attendance overview: response is null.', level: LogLevel.error);
logSafe('Failed to fetch attendance overview: response is null.',
level: LogLevel.error);
}
} catch (e, st) {
roleWiseData.clear();
logSafe(
'Error fetching attendance overview',
level: LogLevel.error,
error: e,
stackTrace: st,
);
logSafe('Error fetching attendance overview',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isLoading.value = false;
isAttendanceLoading.value = false;
}
}
Future<void> fetchProjectProgress() async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
try {
isProjectLoading.value = true;
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);
} 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;
final response = await ApiService.getDashboardTasks(projectId: projectId);
if (response != null && response.success) {
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;
final response = await ApiService.getDashboardTeams(projectId: projectId);
if (response != null && response.success) {
totalEmployees.value = response.data?.totalEmployees ?? 0;
inToday.value = response.data?.inToday ?? 0;
logSafe('Dashboard teams fetched', level: LogLevel.info);
} else {
totalEmployees.value = 0;
inToday.value = 0;
logSafe('Failed to fetch teams', level: LogLevel.error);
}
} catch (e, st) {
totalEmployees.value = 0;
inToday.value = 0;
logSafe('Error fetching teams',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isTeamsLoading.value = false;
}
}
}

View File

@ -3,6 +3,7 @@ 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';
class AddCommentController extends GetxController {
final String contactId;
@ -39,6 +40,10 @@ class AddCommentController extends GetxController {
final directoryController = Get.find<DirectoryController>();
await directoryController.fetchCommentsForContact(contactId);
final notesController = Get.find<NotesController>();
await notesController.fetchNotes(
pageSize: 1000, pageNumber: 1); // Fixed here
Get.back(result: true);
showAppSnackbar(
@ -46,13 +51,6 @@ class AddCommentController extends GetxController {
message: "Your comment has been successfully added.",
type: SnackbarType.success,
);
} else {
logSafe("Comment submission failed", level: LogLevel.error);
showAppSnackbar(
title: "Submission Failed",
message: "Unable to add the comment. Please try again later.",
type: SnackbarType.error,
);
}
} catch (e) {
logSafe("Error while submitting comment: $e", level: LogLevel.error);

View File

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

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

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/employee_model.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';

View File

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

View File

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

View File

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

View File

@ -0,0 +1,181 @@
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';
class DocumentController extends GetxController {
// ------------------ Observables ---------------------
var isLoading = false.obs;
var documents = <DocumentItem>[].obs;
var filters = Rxn<DocumentFiltersData>();
// Selected filters (multi-select support)
var selectedUploadedBy = <String>[].obs;
var selectedCategory = <String>[].obs;
var selectedType = <String>[].obs;
var selectedTag = <String>[].obs;
// Pagination state
var pageNumber = 1.obs;
final int pageSize = 20;
var hasMore = true.obs;
// Error message
var errorMessage = "".obs;
// NEW: show inactive toggle
var 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>();
// ------------------ API Calls -----------------------
/// Fetch Document Filters for an 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";
}
} catch (e) {
errorMessage.value = "Error fetching filters: $e";
} finally {
isLoading.value = false;
}
}
/// Toggle document active/inactive state
Future<bool> toggleDocumentActive(
String id, {
required bool isActive,
required String entityTypeId,
required String entityId,
}) async {
try {
isLoading.value = true;
final success =
await ApiService.deleteDocumentApi(id: id, isActive: isActive);
if (success) {
// 🔥 Always fetch fresh list after toggle
await fetchDocuments(
entityTypeId: entityTypeId,
entityId: entityId,
reset: true,
);
return true;
} else {
errorMessage.value = "Failed to update document state";
return false;
}
} catch (e) {
errorMessage.value = "Error updating document: $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
Future<void> fetchDocuments({
required String entityTypeId,
required String entityId,
String? filter,
String? searchString,
bool reset = false,
}) async {
try {
if (reset) {
pageNumber.value = 1;
documents.clear();
hasMore.value = true;
}
if (!hasMore.value) return;
isLoading.value = true;
final response = await ApiService.getDocumentListApi(
entityTypeId: entityTypeId,
entityId: entityId,
filter: filter ?? "",
searchString: searchString ?? searchQuery.value,
pageNumber: pageNumber.value,
pageSize: pageSize,
isActive: !showInactive.value,
);
if (response != null && response.success) {
if (response.data.data.isNotEmpty) {
documents.addAll(response.data.data);
pageNumber.value++;
} else {
hasMore.value = false;
}
} else {
errorMessage.value = response?.message ?? "Failed to fetch documents";
}
} catch (e) {
errorMessage.value = "Error fetching documents: $e";
} finally {
isLoading.value = false;
}
}
// ------------------ Helpers -----------------------
/// Clear selected filters
void clearFilters() {
selectedUploadedBy.clear();
selectedCategory.clear();
selectedType.clear();
selectedTag.clear();
isUploadedAt.value = true;
isVerified.value = null;
startDate.value = null;
endDate.value = null;
}
/// Check if any filters are active (for red dot indicator)
bool hasActiveFilters() {
return selectedUploadedBy.isNotEmpty ||
selectedCategory.isNotEmpty ||
selectedType.isNotEmpty ||
selectedTag.isNotEmpty;
}
}

View File

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

View File

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

View File

@ -0,0 +1,145 @@
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';
class AssignProjectController extends GetxController {
final String employeeId;
final String jobRoleId;
AssignProjectController({
required this.employeeId,
required this.jobRoleId,
});
final ProjectController projectController = Get.put(ProjectController());
RxBool isLoading = false.obs;
RxBool isAssigning = false.obs;
RxList<String> assignedProjectIds = <String>[].obs;
RxList<String> selectedProjects = <String>[].obs;
RxList<GlobalProjectModel> allProjects = <GlobalProjectModel>[].obs;
RxList<GlobalProjectModel> filteredProjects = <GlobalProjectModel>[].obs;
@override
void onInit() {
super.onInit();
WidgetsBinding.instance.addPostFrameCallback((_) {
fetchAllProjectsAndAssignments();
});
}
/// Fetch all projects and assigned projects
Future<void> fetchAllProjectsAndAssignments() async {
isLoading.value = true;
try {
await projectController.fetchProjects();
allProjects.assignAll(projectController.projects);
filteredProjects.assignAll(allProjects); // initially show all
final responseList = await ApiService.getAssignedProjects(employeeId);
if (responseList != null) {
final assignedProjects =
responseList.map((e) => AssignedProject.fromJson(e)).toList();
assignedProjectIds.assignAll(
assignedProjects.map((p) => p.id).toList(),
);
selectedProjects.assignAll(assignedProjectIds);
}
logSafe("All Projects: ${allProjects.map((e) => e.id)}");
logSafe("Assigned Project IDs: $assignedProjectIds");
} catch (e, stack) {
logSafe("Error fetching projects or assignments: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
} finally {
isLoading.value = false;
}
}
/// Assign selected projects
Future<bool> assignProjectsToEmployee() async {
if (selectedProjects.isEmpty) {
logSafe("No projects selected for assignment.", level: LogLevel.warning);
return false;
}
final List<Map<String, dynamic>> projectPayload =
selectedProjects.map((id) {
return {"projectId": id, "jobRoleId": jobRoleId, "status": true};
}).toList();
isAssigning.value = true;
try {
final success = await ApiService.assignProjects(
employeeId: employeeId,
projects: projectPayload,
);
if (success) {
logSafe("Projects assigned successfully.");
assignedProjectIds.assignAll(selectedProjects);
return true;
} else {
logSafe("Failed to assign projects.", level: LogLevel.error);
return false;
}
} catch (e, stack) {
logSafe("Error assigning projects: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return false;
} finally {
isAssigning.value = false;
}
}
/// Toggle project selection
void toggleProjectSelection(String projectId, bool isSelected) {
if (isSelected) {
if (!selectedProjects.contains(projectId)) {
selectedProjects.add(projectId);
}
} else {
selectedProjects.remove(projectId);
}
}
/// Check if project is selected
bool isProjectSelected(String projectId) {
return selectedProjects.contains(projectId);
}
/// Select all / deselect all
void toggleSelectAll() {
if (areAllSelected()) {
selectedProjects.clear();
} else {
selectedProjects.assignAll(allProjects.map((p) => p.id.toString()));
}
}
/// Are all selected?
bool areAllSelected() {
return selectedProjects.length == allProjects.length &&
allProjects.isNotEmpty;
}
/// Filter projects by search text
void filterProjects(String query) {
if (query.isEmpty) {
filteredProjects.assignAll(allProjects);
} else {
filteredProjects.assignAll(
allProjects
.where((p) => p.name.toLowerCase().contains(query.toLowerCase()))
.toList(),
);
}
}
}

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/attendance_model.dart';
import 'package:marco/model/attendance/attendance_model.dart';
import 'package:marco/model/project_model.dart';
import 'package:marco/model/employee_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';
@ -17,24 +17,25 @@ class EmployeesScreenController extends GetxController {
RxBool isLoading = false.obs;
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
Rxn<EmployeeDetailsModel> selectedEmployeeDetails = Rxn<EmployeeDetailsModel>();
Rxn<EmployeeDetailsModel> selectedEmployeeDetails =
Rxn<EmployeeDetailsModel>();
RxBool isLoadingEmployeeDetails = false.obs;
@override
void onInit() {
super.onInit();
fetchAllProjects();
final projectId = Get.find<ProjectController>().selectedProject?.id;
if (projectId != null) {
selectedProjectId = projectId;
fetchEmployeesByProject(projectId);
} else if (isAllEmployeeSelected.value) {
fetchAllEmployees();
} else {
clearEmployees();
}
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 {
@ -50,7 +51,8 @@ class EmployeesScreenController extends GetxController {
);
},
onEmpty: () {
logSafe("No project data found or API call failed.", level: LogLevel.warning);
logSafe("No project data found or API call failed.",
level: LogLevel.warning);
},
);
@ -64,11 +66,13 @@ class EmployeesScreenController extends GetxController {
update(['employee_screen_controller']);
}
Future<void> fetchAllEmployees() async {
Future<void> fetchAllEmployees({String? organizationId}) async {
isLoading.value = true;
update(['employee_screen_controller']);
await _handleApiCall(
ApiService.getAllEmployees,
() => ApiService.getAllEmployees(
organizationId: organizationId), // pass orgId to API
onSuccess: (data) {
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
logSafe(
@ -78,7 +82,10 @@ class EmployeesScreenController extends GetxController {
},
onEmpty: () {
employees.clear();
logSafe("No Employee data found or API call failed.", level: LogLevel.warning);
logSafe(
"No Employee data found or API call failed",
level: LogLevel.warning,
);
},
);
@ -86,36 +93,22 @@ class EmployeesScreenController extends GetxController {
update(['employee_screen_controller']);
}
Future<void> fetchEmployeesByProject(String? projectId) async {
if (projectId == null || projectId.isEmpty) {
logSafe("Project ID is required but was null or empty.", level: LogLevel.error);
return;
}
Future<void> fetchEmployeesByProject(String projectId,
{String? organizationId}) async {
if (projectId.isEmpty) return;
isLoading.value = true;
await _handleApiCall(
() => ApiService.getAllEmployeesByProject(projectId),
() => 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;
}
logSafe(
"Employees fetched: ${employees.length} for project $projectId",
level: LogLevel.info,
);
},
onEmpty: () {
employees.clear();
logSafe("No employees found for project $projectId.", level: LogLevel.warning, );
},
onError: (e) {
logSafe("Error fetching employees for project $projectId", level: LogLevel.error, error: e, );
},
onEmpty: () => employees.clear(),
);
isLoading.value = false;
@ -131,15 +124,25 @@ 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,
);
},
);

View File

@ -0,0 +1,497 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.dart';
import 'package:mime/mime.dart';
import 'package:image_picker/image_picker.dart';
import 'package: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';
class AddExpenseController extends GetxController {
// --- Text Controllers ---
final controllers = <TextEditingController>[
TextEditingController(), // amount
TextEditingController(), // description
TextEditingController(), // supplier
TextEditingController(), // transactionId
TextEditingController(), // gst
TextEditingController(), // location
TextEditingController(), // transactionDate
TextEditingController(), // noOfPersons
TextEditingController(), // employeeSearch
];
TextEditingController get amountController => controllers[0];
TextEditingController get descriptionController => controllers[1];
TextEditingController get supplierController => controllers[2];
TextEditingController get transactionIdController => controllers[3];
TextEditingController get gstController => controllers[4];
TextEditingController get locationController => controllers[5];
TextEditingController get transactionDateController => controllers[6];
TextEditingController get noOfPersonsController => controllers[7];
TextEditingController get employeeSearchController => controllers[8];
// --- Reactive State ---
final isLoading = false.obs;
final isSubmitting = false.obs;
final isFetchingLocation = false.obs;
final isEditMode = false.obs;
final isSearchingEmployees = false.obs;
// --- Dropdown Selections & Data ---
final selectedPaymentMode = Rxn<PaymentModeModel>();
final selectedExpenseType = Rxn<ExpenseTypeModel>();
final selectedPaidBy = Rxn<EmployeeModel>();
final selectedProject = ''.obs;
final selectedTransactionDate = Rxn<DateTime>();
final attachments = <File>[].obs;
final existingAttachments = <Map<String, dynamic>>[].obs;
final globalProjects = <String>[].obs;
final projectsMap = <String, String>{}.obs;
final expenseTypes = <ExpenseTypeModel>[].obs;
final paymentModes = <PaymentModeModel>[].obs;
final allEmployees = <EmployeeModel>[].obs;
final employeeSearchResults = <EmployeeModel>[].obs;
String? editingExpenseId;
final expenseController = Get.find<ExpenseController>();
final ImagePicker _picker = ImagePicker();
@override
void onInit() {
super.onInit();
loadMasterData();
employeeSearchController.addListener(
() => searchEmployees(employeeSearchController.text),
);
}
@override
void onClose() {
for (var c in controllers) {
c.dispose();
}
super.onClose();
}
// --- Employee Search ---
Future<void> searchEmployees(String query) async {
if (query.trim().isEmpty) return employeeSearchResults.clear();
isSearchingEmployees.value = true;
try {
final data = await ApiService.searchEmployeesBasic(
searchString: query.trim(),
);
if (data is List) {
employeeSearchResults.assignAll(
data
.map((e) => EmployeeModel.fromJson(e as Map<String, dynamic>))
.toList(),
);
} else {
employeeSearchResults.clear();
}
} catch (e) {
logSafe("Error searching employees: $e", level: LogLevel.error);
employeeSearchResults.clear();
} finally {
isSearchingEmployees.value = false;
}
}
// --- Form Population (Edit) ---
Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
isEditMode.value = true;
editingExpenseId = '${data['id']}';
selectedProject.value = data['projectName'] ?? '';
amountController.text = '${data['amount'] ?? ''}';
supplierController.text = data['supplerName'] ?? '';
descriptionController.text = data['description'] ?? '';
transactionIdController.text = data['transactionId'] ?? '';
locationController.text = data['location'] ?? '';
noOfPersonsController.text = '${data['noOfPersons'] ?? 0}';
_setTransactionDate(data['transactionDate']);
_setDropdowns(data);
await _setPaidBy(data);
_setAttachments(data['attachments']);
_logPrefilledData();
}
void _setTransactionDate(dynamic dateStr) {
if (dateStr == null) {
selectedTransactionDate.value = null;
transactionDateController.clear();
return;
}
try {
final parsed = DateTime.parse(dateStr);
selectedTransactionDate.value = parsed;
transactionDateController.text = DateFormat('dd-MM-yyyy').format(parsed);
} catch (_) {
selectedTransactionDate.value = null;
transactionDateController.clear();
}
}
void _setDropdowns(Map<String, dynamic> data) {
selectedExpenseType.value =
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
selectedPaymentMode.value =
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
}
Future<void> _setPaidBy(Map<String, dynamic> data) async {
final paidById = '${data['paidById']}';
selectedPaidBy.value =
allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
if (selectedPaidBy.value == null && data['paidByFirstName'] != null) {
await searchEmployees(
'${data['paidByFirstName']} ${data['paidByLastName']}',
);
selectedPaidBy.value = employeeSearchResults
.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
}
}
void _setAttachments(dynamic attachmentsData) {
existingAttachments.clear();
if (attachmentsData is List) {
existingAttachments.addAll(
List<Map<String, dynamic>>.from(attachmentsData).map(
(e) => {...e, 'isActive': true},
),
);
}
}
void _logPrefilledData() {
final info = [
'ID: $editingExpenseId',
'Project: ${selectedProject.value}',
'Amount: ${amountController.text}',
'Supplier: ${supplierController.text}',
'Description: ${descriptionController.text}',
'Transaction ID: ${transactionIdController.text}',
'Location: ${locationController.text}',
'Transaction Date: ${transactionDateController.text}',
'No. of Persons: ${noOfPersonsController.text}',
'Expense Type: ${selectedExpenseType.value?.name}',
'Payment Mode: ${selectedPaymentMode.value?.name}',
'Paid By: ${selectedPaidBy.value?.name}',
'Attachments: ${attachments.length}',
'Existing Attachments: ${existingAttachments.length}',
];
for (var line in info) {
logSafe(line, level: LogLevel.info);
}
}
// --- Pickers ---
Future<void> pickTransactionDate(BuildContext context) async {
final pickedDate = await showDatePicker(
context: context,
initialDate: selectedTransactionDate.value ?? DateTime.now(),
firstDate: DateTime(DateTime.now().year - 5),
lastDate: DateTime.now(),
);
if (pickedDate != null) {
final now = DateTime.now();
final finalDateTime = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
now.hour,
now.minute,
now.second,
);
selectedTransactionDate.value = finalDateTime;
transactionDateController.text =
DateFormat('dd MMM yyyy').format(finalDateTime);
}
}
Future<void> pickAttachments() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'],
allowMultiple: true,
);
if (result != null) {
attachments.addAll(
result.paths.whereType<String>().map(File.new),
);
}
} catch (e) {
_errorSnackbar("Attachment error: $e");
}
}
void removeAttachment(File file) => attachments.remove(file);
Future<void> pickFromCamera() async {
try {
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) attachments.add(File(pickedFile.path));
} catch (e) {
_errorSnackbar("Camera error: $e");
}
}
// --- Location ---
Future<void> fetchCurrentLocation() async {
isFetchingLocation.value = true;
try {
if (!await _ensureLocationPermission()) return;
final position = await Geolocator.getCurrentPosition();
final placemarks =
await placemarkFromCoordinates(position.latitude, position.longitude);
locationController.text = placemarks.isNotEmpty
? [
placemarks.first.name,
placemarks.first.street,
placemarks.first.locality,
placemarks.first.administrativeArea,
placemarks.first.country,
].where((e) => e?.isNotEmpty == true).join(", ")
: "${position.latitude}, ${position.longitude}";
} catch (e) {
_errorSnackbar("Location error: $e");
} finally {
isFetchingLocation.value = false;
}
}
Future<bool> _ensureLocationPermission() async {
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
_errorSnackbar("Location permission denied.");
return false;
}
}
if (!await Geolocator.isLocationServiceEnabled()) {
_errorSnackbar("Location service disabled.");
return false;
}
return true;
}
// --- Data Fetching ---
Future<void> loadMasterData() async =>
Future.wait([fetchMasterData(), fetchGlobalProjects()]);
Future<void> fetchMasterData() async {
try {
final types = await ApiService.getMasterExpenseTypes();
if (types is List) {
expenseTypes.value = types
.map((e) => ExpenseTypeModel.fromJson(e as Map<String, dynamic>))
.toList();
}
final modes = await ApiService.getMasterPaymentModes();
if (modes is List) {
paymentModes.value = modes
.map((e) => PaymentModeModel.fromJson(e as Map<String, dynamic>))
.toList();
}
} catch (_) {
_errorSnackbar("Failed to fetch master data");
}
}
Future<void> fetchGlobalProjects() async {
try {
final response = await ApiService.getGlobalProjects();
if (response != null) {
final names = <String>[];
for (var item in response) {
final name = item['name']?.toString().trim();
final id = item['id']?.toString().trim();
if (name != null && id != null) {
projectsMap[name] = id;
names.add(name);
}
}
globalProjects.assignAll(names);
}
} catch (e) {
logSafe("Error fetching projects: $e", level: LogLevel.error);
}
}
// --- Submission ---
Future<void> submitOrUpdateExpense() async {
if (isSubmitting.value) return;
isSubmitting.value = true;
try {
final validationMsg = validateForm();
if (validationMsg.isNotEmpty) {
_errorSnackbar(validationMsg, "Missing Fields");
return;
}
final payload = await _buildExpensePayload();
final success = await _submitToApi(payload);
if (success) {
await expenseController.fetchExpenses();
Get.back();
showAppSnackbar(
title: "Success",
message:
"Expense ${isEditMode.value ? 'updated' : 'created'} successfully!",
type: SnackbarType.success,
);
} else {
_errorSnackbar("Operation failed. Try again.");
}
} catch (e) {
_errorSnackbar("Unexpected error: $e");
} finally {
isSubmitting.value = false;
}
}
Future<bool> _submitToApi(Map<String, dynamic> payload) async {
if (isEditMode.value && editingExpenseId != null) {
return ApiService.editExpenseApi(
expenseId: editingExpenseId!,
payload: payload,
);
}
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 {
final now = DateTime.now();
final existingPayload = isEditMode.value
? existingAttachments
.map((e) => {
"documentId": e['documentId'],
"fileName": e['fileName'],
"contentType": e['contentType'],
"fileSize": 0,
"description": "",
"url": e['url'],
"isActive": e['isActive'] ?? true,
"base64Data": "",
})
.toList()
: <Map<String, dynamic>>[];
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": "",
};
}),
);
final type = selectedExpenseType.value!;
return {
if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId,
"projectId": projectsMap[selectedProject.value]!,
"expensesTypeId": type.id,
"paymentModeId": selectedPaymentMode.value!.id,
"paidById": selectedPaidBy.value!.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
? int.tryParse(noOfPersonsController.text.trim()) ?? 0
: 0,
"billAttachments": [
...existingPayload,
...newPayload,
].isEmpty
? null
: [...existingPayload, ...newPayload],
};
}
String validateForm() {
final missing = <String>[];
if (selectedProject.value.isEmpty) missing.add("Project");
if (selectedExpenseType.value == null) missing.add("Expense Type");
if (selectedPaymentMode.value == null) missing.add("Payment Mode");
if (selectedPaidBy.value == null) missing.add("Paid By");
if (amountController.text.trim().isEmpty) missing.add("Amount");
if (descriptionController.text.trim().isEmpty) missing.add("Description");
if (selectedTransactionDate.value == null) {
missing.add("Transaction Date");
} else if (selectedTransactionDate.value!.isAfter(DateTime.now())) {
missing.add("Valid Transaction Date");
}
if (double.tryParse(amountController.text.trim()) == null) {
missing.add("Valid Amount");
}
final hasActiveExisting =
existingAttachments.any((e) => e['isActive'] != false);
if (attachments.isEmpty && !hasActiveExisting) {
missing.add("Attachment");
}
return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}.";
}
// --- Snackbar Helper ---
void _errorSnackbar(String msg, [String title = "Error"]) {
showAppSnackbar(title: title, message: msg, type: SnackbarType.error);
}
}

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ 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/employee_info.dart';
import 'package:marco/model/employees/employee_info.dart';
import 'package:marco/model/projects_model.dart';
class PermissionController extends GetxController {
@ -13,6 +13,7 @@ class PermissionController extends GetxController {
var employeeInfo = Rxn<EmployeeInfo>();
var projectsInfo = <ProjectInfo>[].obs;
Timer? _refreshTimer;
var isLoading = true.obs;
@override
void onInit() {
@ -26,7 +27,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 +39,24 @@ 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.");
} 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 +67,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,7 +97,8 @@ 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);
}
}
@ -100,23 +109,43 @@ class PermissionController extends GetxController {
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);
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;
}
List<String> get allowedPermissionIds {
final ids = permissions.map((p) => p.id).toList();
logSafe("[PermissionController] Allowed Permission IDs: $ids",
level: LogLevel.debug);
return ids;
}
bool hasAnyPermission(List<String> ids) {
logSafe("[PermissionController] Checking if any of these are allowed: $ids",
level: LogLevel.debug);
final allowed = allowedPermissionIds;
final result = ids.any((id) => allowed.contains(id));
logSafe("[PermissionController] Permission match result: $result",
level: LogLevel.debug);
return result;
}
@override
void onClose() {
_refreshTimer?.cancel();

View File

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

View File

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

View File

@ -0,0 +1,198 @@
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:marco/model/dailyTaskPlanning/daily_progress_report_filter_response_model.dart';
class DailyTaskController extends GetxController {
List<ProjectModel> projects = [];
String? selectedProjectId;
DateTime? startDateTask;
DateTime? endDateTask;
List<TaskModel> dailyTasks = [];
final RxSet<String> expandedDates = <String>{}.obs;
void toggleDate(String dateKey) {
if (expandedDates.contains(dateKey)) {
expandedDates.remove(dateKey);
} else {
expandedDates.add(dateKey);
}
}
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;
@override
void onInit() {
super.onInit();
_initializeDefaults();
}
void _initializeDefaults() {
_setDefaultDateRange();
}
void _setDefaultDateRange() {
final today = DateTime.now();
startDateTask = today.subtract(const Duration(days: 7));
endDateTask = today;
logSafe(
"Default date range set: $startDateTask to $endDateTask",
level: LogLevel.info,
);
}
void clearTaskFilters() {
selectedBuildings.clear();
selectedFloors.clear();
selectedActivities.clear();
selectedServices.clear();
startDateTask = null;
endDateTask = null;
update();
}
Future<void> fetchTaskData(
String projectId, {
int pageNumber = 1,
int pageSize = 20,
bool isLoadMore = false,
}) async {
if (!isLoadMore) {
isLoading.value = true;
currentPage = 1;
hasMore = true;
groupedDailyTasks.clear();
dailyTasks.clear();
} else {
isLoadingMore.value = true;
}
// Create the filter object
final filter = {
"buildingIds": selectedBuildings.toList(),
"floorIds": selectedFloors.toList(),
"activityIds": selectedActivities.toList(),
"serviceIds": selectedServices.toList(),
"dateFrom": startDateTask?.toIso8601String(),
"dateTo": endDateTask?.toIso8601String(),
};
final response = await ApiService.getDailyTasks(
projectId,
filter: filter,
pageNumber: pageNumber,
pageSize: pageSize,
);
if (response != null && response.isNotEmpty) {
for (var task in response) {
final assignmentDateKey =
task.assignmentDate.toIso8601String().split('T')[0];
groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []).add(task);
}
dailyTasks = groupedDailyTasks.values.expand((list) => list).toList();
currentPage = pageNumber;
} else {
hasMore = false;
}
isLoading.value = false;
isLoadingMore.value = false;
update();
}
FilterData? taskFilterData;
Future<void> fetchTaskFilter(String projectId) async {
isFilterLoading.value = true;
try {
final filterResponse = await ApiService.getDailyTaskFilter(projectId);
if (filterResponse != null && filterResponse.success) {
taskFilterData =
filterResponse.data; // now taskFilterData is FilterData?
logSafe(
"Task filter fetched successfully. Buildings: ${taskFilterData?.buildings.length}, Floors: ${taskFilterData?.floors.length}",
level: LogLevel.info,
);
} else {
logSafe(
"Failed to fetch task filter for projectId: $projectId",
level: LogLevel.warning,
);
}
} catch (e, stack) {
logSafe("Exception in fetchTaskFilter: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
} finally {
isFilterLoading.value = false;
update();
}
}
Future<void> selectDateRangeForTaskData(
BuildContext context,
DailyTaskController controller,
) async {
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2022),
lastDate: DateTime.now(),
initialDateRange: DateTimeRange(
start:
startDateTask ?? DateTime.now().subtract(const Duration(days: 7)),
end: endDateTask ?? DateTime.now(),
),
);
if (picked == null) {
logSafe("Date range picker cancelled by user.", level: LogLevel.debug);
return;
}
startDateTask = picked.start;
endDateTask = picked.end;
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);
} else {
logSafe("Project ID is null or empty, skipping fetchTaskData",
level: LogLevel.warning);
}
}
void refreshTasksFromNotification({
required String projectId,
required String taskAllocationId,
}) async {
// re-fetch tasks
await fetchTaskData(projectId);
update(); // rebuilds UI
}
}

View File

@ -0,0 +1,264 @@
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';
class DailyTaskPlanningController extends GetxController {
List<ProjectModel> projects = [];
RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs;
List<EmployeeModel> allEmployeesCache = [];
List<TaskPlanningDetailsModel> dailyTasks = [];
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
MyFormValidator basicValidator = MyFormValidator();
List<Map<String, dynamic>> roles = [];
RxBool isAssigningTask = false.obs;
RxnString selectedRoleId = RxnString();
RxBool isFetchingTasks = true.obs;
RxBool isFetchingProjects = true.obs;
RxBool isFetchingEmployees = true.obs;
@override
void onInit() {
super.onInit();
fetchRoles();
}
String? formFieldValidator(String? value, {required String fieldType}) {
if (value == null || value.trim().isEmpty) return 'This field is required';
if (fieldType == "target" && int.tryParse(value.trim()) == null) {
return 'Please enter a valid number';
}
if (fieldType == "description" && value.trim().length < 5) {
return 'Description must be at least 5 characters';
}
return null;
}
void updateSelectedEmployees() {
selectedEmployees.value =
employees.where((e) => uploadingStates[e.id]?.value == true).toList();
logSafe("Updated selected employees", level: LogLevel.debug);
}
void onRoleSelected(String? roleId) {
selectedRoleId.value = roleId;
logSafe("Role selected", level: LogLevel.info);
}
Future<void> fetchRoles() async {
logSafe("Fetching roles...", level: LogLevel.info);
final result = await ApiService.getRoles();
if (result != null) {
roles = List<Map<String, dynamic>>.from(result);
logSafe("Roles fetched successfully", level: LogLevel.info);
update();
} else {
logSafe("Failed to fetch roles", level: LogLevel.error);
}
}
Future<bool> assignDailyTask({
required String workItemId,
required int plannedTask,
required String description,
required List<String> taskTeam,
DateTime? assignmentDate,
String? organizationId,
String? serviceId,
}) async {
isAssigningTask.value = true;
logSafe("Starting assign task...", level: LogLevel.info);
final response = await ApiService.assignDailyTask(
workItemId: workItemId,
plannedTask: plannedTask,
description: description,
taskTeam: taskTeam,
assignmentDate: assignmentDate,
organizationId: organizationId,
serviceId: serviceId,
);
isAssigningTask.value = false;
if (response == true) {
logSafe("Task assigned successfully", level: LogLevel.info);
showAppSnackbar(
title: "Success",
message: "Task assigned successfully!",
type: SnackbarType.success,
);
return true;
} else {
logSafe("Failed to assign task", level: LogLevel.error);
showAppSnackbar(
title: "Error",
message: "Failed to assign task.",
type: SnackbarType.error,
);
return false;
}
}
/// Fetch Infra details and then tasks per work area
Future<void> fetchTaskData(String? projectId, {String? serviceId}) async {
if (projectId == null) return;
isFetchingTasks.value = true;
try {
final infraResponse = await ApiService.getInfraDetails(
projectId,
serviceId: serviceId,
);
final infraData = infraResponse?['data'] as List<dynamic>?;
if (infraData == null || infraData.isEmpty) {
dailyTasks = [];
return;
}
dailyTasks = infraData.map((buildingJson) {
final building = Building(
id: buildingJson['id'],
name: buildingJson['buildingName'],
description: buildingJson['description'],
floors: (buildingJson['floors'] as List<dynamic>)
.map((floorJson) => Floor(
id: floorJson['id'],
floorName: floorJson['floorName'],
workAreas: (floorJson['workAreas'] as List<dynamic>)
.map((areaJson) => WorkArea(
id: areaJson['id'],
areaName: areaJson['areaName'],
workItems: [],
))
.toList(),
))
.toList(),
);
return TaskPlanningDetailsModel(
id: building.id,
name: building.name,
projectAddress: "",
contactPerson: "",
startDate: DateTime.now(),
endDate: DateTime.now(),
projectStatusId: "",
buildings: [building],
);
}).toList();
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);
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);
}
}));
} catch (e, stack) {
logSafe("Error fetching daily task data",
level: LogLevel.error, error: e, stackTrace: stack);
} finally {
isFetchingTasks.value = false;
update();
}
}
Future<void> fetchEmployeesByProjectService({
required String projectId,
String? serviceId,
String? organizationId,
}) async {
isFetchingEmployees.value = true;
try {
final response = await ApiService.getEmployeesByProjectService(
projectId,
serviceId: serviceId ?? '',
organizationId: organizationId ?? '',
);
if (response != null && response.isNotEmpty) {
employees.assignAll(response.map((json) => EmployeeModel.fromJson(json)));
if (serviceId == null && organizationId == null) {
allEmployeesCache = List.from(employees);
}
final currentEmployeeIds = employees.map((e) => e.id).toSet();
uploadingStates.removeWhere((key, _) => !currentEmployeeIds.contains(key));
employees.forEach((emp) {
uploadingStates.putIfAbsent(emp.id, () => false.obs);
});
selectedEmployees.removeWhere((e) => !currentEmployeeIds.contains(e.id));
logSafe("Employees fetched: ${employees.length}", level: LogLevel.info);
} else {
employees.clear();
uploadingStates.clear();
selectedEmployees.clear();
logSafe(
serviceId != null || organizationId != null
? "Filtered employees empty"
: "No employees found",
level: LogLevel.warning,
);
}
} catch (e, stack) {
logSafe("Error fetching employees", level: LogLevel.error, error: e, stackTrace: stack);
if (serviceId == null && organizationId == null && allEmployeesCache.isNotEmpty) {
employees.assignAll(allEmployeesCache);
final cachedEmployeeIds = employees.map((e) => e.id).toSet();
uploadingStates.removeWhere((key, _) => !cachedEmployeeIds.contains(key));
employees.forEach((emp) {
uploadingStates.putIfAbsent(emp.id, () => false.obs);
});
selectedEmployees.removeWhere((e) => !cachedEmployeeIds.contains(e.id));
} else {
employees.clear();
uploadingStates.clear();
selectedEmployees.clear();
}
} finally {
isFetchingEmployees.value = false;
update();
}
}
}

View File

@ -7,12 +7,12 @@ import 'package:image_picker/image_picker.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/controller/my_controller.dart';
import 'package:marco/controller/task_planing/daily_task_planing_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/dailyTaskPlaning/work_status_model.dart';
import 'package:marco/model/dailyTaskPlanning/work_status_model.dart';
enum ApiStatus { idle, loading, success, failure }
@ -34,7 +34,7 @@ class ReportTaskActionController extends MyController {
final RxString selectedWorkStatusName = ''.obs;
final MyFormValidator basicValidator = MyFormValidator();
final DailyTaskPlaningController taskController = Get.put(DailyTaskPlaningController());
final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController());
final ImagePicker _picker = ImagePicker();
final assignedDateController = TextEditingController();

View File

@ -6,7 +6,7 @@ import 'package:marco/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_planing/daily_task_planing_controller.dart';
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
import 'dart:convert';
@ -14,7 +14,7 @@ import 'package:marco/helpers/widgets/my_image_compressor.dart';
enum ApiStatus { idle, loading, success, failure }
final DailyTaskPlaningController taskController = Get.put(DailyTaskPlaningController());
final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController());
final ImagePicker _picker = ImagePicker();
class ReportTaskController extends MyController {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,136 @@
import 'package:get/get.dart';
import 'package: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:marco/controller/permission_controller.dart';
class TenantSelectionController extends GetxController {
final TenantService _tenantService = TenantService();
// Tenant list
final tenants = <Tenant>[].obs;
// Loading state
final isLoading = false.obs;
// Selected tenant ID
final selectedTenantId = RxnString();
// Flag to indicate auto-selection (for splash screen)
final isAutoSelecting = false.obs;
@override
void onInit() {
super.onInit();
loadTenants();
}
/// Load tenants and handle auto-selection
Future<void> loadTenants() async {
isLoading.value = true;
isAutoSelecting.value = true; // show splash during auto-selection
try {
final data = await _tenantService.getTenants();
if (data == null || data.isEmpty) {
tenants.clear();
logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning);
return;
}
tenants.value = data.map((e) => Tenant.fromJson(e)).toList();
final recentTenantId = LocalStorage.getRecentTenantId();
// Auto-select if only one tenant
if (tenants.length == 1) {
await _selectTenant(tenants.first.id);
}
// Auto-select recent tenant if available
else if (recentTenantId != null) {
final recentTenant =
tenants.firstWhereOrNull((t) => t.id == recentTenantId);
if (recentTenant != null) {
await _selectTenant(recentTenant.id);
} else {
_clearSelection();
}
}
// No auto-selection
else {
_clearSelection();
}
} catch (e, st) {
logSafe("❌ Exception in loadTenants",
level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar(
title: "Error",
message: "Failed to load organizations. Please try again.",
type: SnackbarType.error,
);
} finally {
isLoading.value = false;
isAutoSelecting.value = false; // hide splash
}
}
/// User manually selects a tenant
Future<void> onTenantSelected(String tenantId) async {
isAutoSelecting.value = true;
await _selectTenant(tenantId);
isAutoSelecting.value = false;
}
/// Internal tenant selection logic
Future<void> _selectTenant(String tenantId) async {
try {
isLoading.value = true;
final success = await _tenantService.selectTenant(tenantId);
if (!success) {
showAppSnackbar(
title: "Error",
message: "Unable to select organization. Please try again.",
type: SnackbarType.error,
);
return;
}
// Update tenant & persist
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
TenantService.setSelectedTenant(selectedTenant);
selectedTenantId.value = tenantId;
await LocalStorage.setRecentTenantId(tenantId);
// Load permissions if token exists
final token = LocalStorage.getJwtToken();
if (token != null && token.isNotEmpty) {
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
}
await Get.find<PermissionController>().loadData(token);
}
// Navigate **before changing isAutoSelecting**
await Get.offAllNamed('/dashboard');
// Then hide splash
isAutoSelecting.value = false;
} catch (e) {
showAppSnackbar(
title: "Error",
message: "An unexpected error occurred while selecting organization.",
type: SnackbarType.error,
);
} finally {
isLoading.value = false;
}
}
/// Clear tenant selection
void _clearSelection() {
selectedTenantId.value = null;
TenantService.currentTenant = null;
}
}

View File

@ -0,0 +1,106 @@
import 'package:get/get.dart';
import 'package: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:marco/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,26 +0,0 @@
import 'package:marco/controller/my_controller.dart';
import 'package:marco/helpers/widgets/my_text_utils.dart';
import 'package:flutter/material.dart';
import 'package:marco/model/drag_n_drop_model.dart';
class DragNDropController extends MyController {
List<DragNDropModel> dragNDrop = [];
final scrollController = ScrollController();
final gridViewKey = GlobalKey();
List<String> dummyTexts = List.generate(12, (index) => MyTextUtils.getDummyText(60));
@override
void onInit() {
DragNDropModel.dummyList.then((value) {
dragNDrop = value;
update();
});
super.onInit();
}
void onReorder(int oldIndex, int newIndex) {
final item = dragNDrop.removeAt(oldIndex);
dragNDrop.insert(newIndex, item);
update();
}
}

View File

@ -1,53 +0,0 @@
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:logger/logger.dart';
class FirebaseNotificationService {
final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
final Logger _logger = Logger();
Future<void> initialize() async {
await Firebase.initializeApp();
_logger.i('Firebase initialized.');
NotificationSettings settings = await _firebaseMessaging.requestPermission();
_logger.i('FCM permission status: ${settings.authorizationStatus}');
// Foreground messages
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
_logger.i('🔔 Foreground Notification Received');
_logNotificationDetails(message);
});
// When app is opened from background via notification
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
_logger.i('📲 App opened via notification');
_handleNotificationTap(message);
});
// Background handler registration
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
}
void _handleNotificationTap(RemoteMessage message) {
_logger.i('Notification tapped with data: ${message.data}');
}
void _logNotificationDetails(RemoteMessage message) {
_logger.i('Notification ID: ${message.messageId}');
_logger.i('Title: ${message.notification?.title}');
_logger.i('Body: ${message.notification?.body}');
_logger.i('Data: ${message.data}');
}
}
// Background handler
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
final Logger _logger = Logger();
_logger.i('🕓 Handling background notification...');
_logger.i('Notification ID: ${message.messageId}');
_logger.i('Title: ${message.notification?.title}');
_logger.i('Body: ${message.notification?.body}');
_logger.i('Data: ${message.data}');
}

View File

@ -1,27 +1,38 @@
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";
// Dashboard Screen API Endpoints
static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview";
// Dashboard Module API Endpoints
static const String getDashboardAttendanceOverview =
"/dashboard/attendance-overview";
static const String getDashboardProjectProgress = "/dashboard/progression";
static const String getDashboardTasks = "/dashboard/tasks";
static const String getDashboardTeams = "/dashboard/teams";
static const String getDashboardProjects = "/dashboard/projects";
// Attendance Screen API Endpoints
// Attendance Module API Endpoints
static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic";
static const String getEmployeesByProject = "/attendance/project/team";
static const String getTodaysAttendance = "/attendance/project/team";
static const String getAttendanceLogs = "/attendance/project/log";
static const String getAttendanceLogView = "/attendance/log/attendance";
static const String getRegularizationLogs = "/attendance/regularize";
static const String uploadAttendanceImage = "/attendance/record-image";
// Employee Screen API Endpoints
static const String getAllEmployeesByProject = "/Project/employees/get";
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";
static const String createEmployee = "/employee/manage";
static const String createEmployee = "/employee/app/manage";
static const String getEmployeeInfo = "/employee/profile/get";
static const String assignEmployee = "/employee/profile/get";
static const String getAssignedProjects = "/project/assigned-projects";
static const String assignProjects = "/project/assign-projects";
// Daily Task Screen API Endpoints
// Daily Task Module API Endpoints
static const String getDailyTask = "/task/list";
static const String reportTask = "/task/report";
static const String commentTask = "/task/comment";
@ -31,19 +42,62 @@ 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 Screen API Endpoints
////// Directory Module API Endpoints ///////
static const String getDirectoryContacts = "/directory";
static const String getDirectoryBucketList = "/directory/buckets";
static const String getDirectoryContactDetail = "/directory/notes";
static const String getDirectoryContactCategory = "/master/contact-categories";
static const String getDirectoryContactCategory =
"/master/contact-categories";
static const String getDirectoryContactTags = "/master/contact-tags";
static const String 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";
static const String updateBucket = "/directory/bucket";
static const String assignBucket = "/directory/assign-bucket";
////// Expense Module API Endpoints
static const String getExpenseCategories = "/expense/categories";
static const String getExpenseList = "/expense/list";
static const String getExpenseDetails = "/expense/details";
static const String createExpense = "/expense/create";
static const String editExpense = "/Expense/edit";
static const String getMasterPaymentModes = "/master/payment-modes";
static const String getMasterExpenseStatus = "/master/expenses-status";
static const String getMasterExpenseTypes = "/master/expenses-types";
static const String updateExpenseStatus = "/expense/action";
static const String deleteExpense = "/expense/delete";
////// Dynamic Menu Module API Endpoints
static const String getDynamicMenu = "/appmenu/get/menu-mobile";
///// Document Module API Endpoints
static const String getMasterDocumentCategories =
"/master/document-category/list";
static const String getMasterDocumentTags = "/document/get/tags";
static const String getDocumentList = "/document/list";
static const String getDocumentDetails = "/document/get/details";
static const String uploadDocument = "/document/upload";
static const String deleteDocument = "/document/delete";
static const String getDocumentFilter = "/document/get/filter";
static const String getDocumentTypesByCategory = "/master/document-type/list";
static const String getDocumentVersion = "/document/get/version";
static const String getDocumentVersions = "/document/list/versions";
static const String editDocument = "/document/edit";
static const String verifyDocument = "/document/verify";
/// Logs Module API Endpoints
static const String uploadLogs = "/log";
static const String getAssignedOrganizations =
"/project/get/assigned/organization";
static const getAllOrganizations = "/organization/list";
static const String getAssignedServices = "/Project/get/assigned/services";
}

File diff suppressed because it is too large Load Diff

View File

@ -1,67 +1,30 @@
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/theme/theme_customizer.dart';
import 'package:marco/helpers/theme/app_theme.dart';
import 'package:url_strategy/url_strategy.dart';
import 'package:firebase_core/firebase_core.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';
Future<void> initializeApp() async {
try {
logSafe("💡 Starting app initialization...");
setPathUrlStrategy();
logSafe("💡 URL strategy set.");
await Future.wait([
_setupUI(),
_setupFirebase(),
_setupLocalStorage(),
]);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Color.fromARGB(255, 255, 0, 0),
statusBarIconBrightness: Brightness.light,
));
logSafe("💡 System UI overlay style set.");
await _setupDeviceInfo();
await _handleAuthTokens();
await _setupTheme();
await _setupFirebaseMessaging();
await LocalStorage.init();
logSafe("💡 Local storage initialized.");
// If a refresh token is found, try to refresh the JWT token
final refreshToken = await LocalStorage.getRefreshToken();
if (refreshToken != null && refreshToken.isNotEmpty) {
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
final success = await AuthService.refreshToken();
if (!success) {
logSafe("⚠️ Refresh token invalid or expired. Skipping controller injection.");
// Optionally, clear tokens and force logout here if needed
}
} else {
logSafe("❌ No refresh token found. Skipping refresh.");
}
await ThemeCustomizer.init();
logSafe("💡 Theme customizer initialized.");
final token = LocalStorage.getString('jwt_token');
if (token != null && token.isNotEmpty) {
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
logSafe("💡 PermissionController injected.");
}
if (!Get.isRegistered<ProjectController>()) {
Get.put(ProjectController(), permanent: true);
logSafe("💡 ProjectController injected as permanent.");
}
// Load data into controllers if required
await Get.find<PermissionController>().loadData(token);
await Get.find<ProjectController>().fetchProjects();
} else {
logSafe("⚠️ No valid JWT token found. Skipping controller initialization.");
}
AppStyle.init();
logSafe("💡 AppStyle initialized.");
_finalizeAppStyle();
logSafe("✅ App initialization completed successfully.");
} catch (e, stacktrace) {
@ -74,3 +37,57 @@ Future<void> initializeApp() async {
rethrow;
}
}
Future<void> _handleAuthTokens() async {
final refreshToken = await LocalStorage.getRefreshToken();
if (refreshToken?.isNotEmpty ?? false) {
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
final success = await AuthService.refreshToken();
if (!success) {
logSafe("⚠️ Refresh token invalid or expired. User must login again.");
}
} else {
logSafe("❌ No refresh token found. Skipping refresh.");
}
}
Future<void> _setupUI() async {
setPathUrlStrategy();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
logSafe("💡 UI setup completed with default system behavior.");
}
Future<void> _setupFirebase() async {
await Firebase.initializeApp();
logSafe("💡 Firebase initialized.");
}
Future<void> _setupLocalStorage() async {
if (!LocalStorage.isInitialized) {
await LocalStorage.init();
logSafe("💡 Local storage initialized.");
} else {
logSafe(" Local storage already initialized, skipping.");
}
}
Future<void> _setupDeviceInfo() async {
final deviceInfoService = DeviceInfoService();
await deviceInfoService.init();
logSafe("📱 Device Info: ${deviceInfoService.deviceData}");
}
Future<void> _setupTheme() async {
await ThemeCustomizer.init();
logSafe("💡 Theme customizer initialized.");
}
Future<void> _setupFirebaseMessaging() async {
await FirebaseNotificationService().initialize();
logSafe("💡 Firebase Messaging initialized.");
}
void _finalizeAppStyle() {
AppStyle.init();
logSafe("💡 AppStyle initialized.");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,391 @@
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';
/// Handles incoming FCM notification actions and updates UI/controllers.
class NotificationActionHandler {
static final Logger _logger = Logger();
/// Main entry point call this for any notification `data` map.
static void handle(Map<String, dynamic> data) {
_logger.i('📲 Handling notification action: $data');
if (data.isEmpty) {
_logger.w('⚠️ Empty notification data received.');
return;
}
final type = data['type'];
final action = data['Action'];
final keyword = data['Keyword'];
if (type != null) {
_handleByType(type, data);
} else if (keyword != null) {
_handleByKeyword(keyword, action, data);
} else {
_logger.w('⚠️ Unhandled notification: $data');
}
}
/// Handle notification if identified by `type`
static void _handleByType(String type, Map<String, dynamic> data) {
switch (type) {
case 'expense_updated':
_handleExpenseUpdated(data);
break;
case 'attendance_updated':
_handleAttendanceUpdated(data);
_handleDashboardUpdate(data); // refresh dashboard attendance
break;
case 'dashboard_update':
_handleDashboardUpdate(data); // full dashboard refresh
break;
default:
_logger.w('⚠️ Unknown notification type: $type');
}
}
/// Handle notification if identified by `keyword`
static void _handleByKeyword(
String keyword, String? action, Map<String, dynamic> data) {
switch (keyword) {
/// 🔹 Attendance
case 'Attendance':
if (_isAttendanceAction(action)) {
_handleAttendanceUpdated(data);
_handleDashboardUpdate(data);
}
break;
case 'Team_Modified':
// Call method to handle team modifications and dashboard update
_handleDashboardUpdate(data);
break;
/// 🔹 Tasks
case 'Report_Task':
_handleTaskUpdated(data, isComment: false);
_handleDashboardUpdate(data);
break;
case 'Task_Comment':
_handleTaskUpdated(data, isComment: true);
_handleDashboardUpdate(data);
break;
case 'Task_Modified':
case 'WorkArea_Modified':
case 'Floor_Modified':
case 'Building_Modified':
_handleTaskPlanningUpdated(data);
_handleDashboardUpdate(data);
break;
/// 🔹 Expenses
case 'Expenses_Modified':
_handleExpenseUpdated(data);
_handleDashboardUpdate(data);
break;
/// 🔹 Documents
case 'Employee_Document_Modified':
case 'Project_Document_Modified':
_handleDocumentModified(data);
break;
/// 🔹 Directory / Contacts
case 'Contact_Modified':
_handleContactModified(data);
break;
case 'Contact_Note_Modified':
_handleContactNoteModified(data);
break;
case 'Bucket_Modified':
_handleBucketModified(data);
break;
case 'Bucket_Assigned':
_handleBucketAssigned(data);
break;
default:
_logger.w('⚠️ Unhandled notification keyword: $keyword');
}
}
/// ---------------------- HANDLERS ----------------------
static void _handleTaskPlanningUpdated(Map<String, dynamic> data) {
final projectId = data['ProjectId'];
if (projectId == null) {
_logger.w("⚠️ TaskPlanning update received without ProjectId: $data");
return;
}
_safeControllerUpdate<DailyTaskPlanningController>(
onFound: (controller) {
controller.fetchTaskData(projectId);
},
notFoundMessage:
'⚠️ DailyTaskPlanningController not found, cannot refresh.',
successMessage:
'✅ DailyTaskPlanningController refreshed from notification.',
);
}
static bool _isAttendanceAction(String? action) {
const validActions = {
'CHECK_IN',
'CHECK_OUT',
'REQUEST_REGULARIZE',
'REQUEST_DELETE',
'REGULARIZE',
'REGULARIZE_REJECT'
};
return validActions.contains(action);
}
static void _handleExpenseUpdated(Map<String, dynamic> data) {
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();
},
notFoundMessage: '⚠️ ExpenseController not found, cannot refresh list.',
successMessage:
'✅ 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) {
await controller.fetchExpenseDetails();
_logger
.i("✅ ExpenseDetailController refreshed for Expense $expenseId");
}
},
notFoundMessage: ' ExpenseDetailController not active, skipping.',
successMessage: '✅ ExpenseDetailController checked for refresh.',
);
}
static void _handleAttendanceUpdated(Map<String, dynamic> data) {
_safeControllerUpdate<AttendanceController>(
onFound: (controller) => controller.refreshDataFromNotification(
projectId: data['ProjectId'],
),
notFoundMessage: '⚠️ AttendanceController not found, cannot update.',
successMessage: '✅ AttendanceController refreshed from notification.',
);
}
static void _handleTaskUpdated(Map<String, dynamic> data,
{required bool isComment}) {
_safeControllerUpdate<DailyTaskController>(
onFound: (controller) => controller.refreshTasksFromNotification(
projectId: data['ProjectId'],
taskAllocationId: data['TaskAllocationId'],
),
notFoundMessage: '⚠️ DailyTaskController not found, cannot update.',
successMessage: '✅ DailyTaskController refreshed from notification.',
);
}
/// ---------------------- DOCUMENT HANDLER ----------------------
static void _handleDocumentModified(Map<String, dynamic> data) {
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'] ?? '';
} else if (data['Keyword'] == 'Project_Document_Modified') {
entityTypeId = Permissions.projectEntity;
entityId = data['ProjectId'] ?? '';
} else {
_logger.w("⚠️ Document update received with unknown keyword: $data");
return;
}
if (entityId.isEmpty) {
_logger.w("⚠️ Document update missing entityId: $data");
return;
}
_logger.i(
"🔔 Document notification received: keyword=${data['Keyword']}, entityTypeId=$entityTypeId, entityId=$entityId, documentId=$documentId");
// Refresh Document List
if (Get.isRegistered<DocumentController>()) {
_safeControllerUpdate<DocumentController>(
onFound: (controller) async {
await controller.fetchDocuments(
entityTypeId: entityTypeId,
entityId: entityId,
reset: true,
);
},
notFoundMessage:
'⚠️ DocumentController not found, cannot refresh list.',
successMessage: '✅ DocumentController refreshed from notification.',
);
} else {
_logger.w('⚠️ DocumentController not registered, skipping list refresh.');
}
// 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");
},
notFoundMessage:
' DocumentDetailsController not active, skipping details refresh.',
successMessage: '✅ DocumentDetailsController checked for refresh.',
);
} else if (documentId != null) {
_logger.w(
'⚠️ DocumentDetailsController not registered, cannot refresh document details.');
}
}
/// ---------------------- DIRECTORY HANDLERS ----------------------
static void _handleContactModified(Map<String, dynamic> data) {
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
if (contactId != null) {
controller.fetchCommentsForContact(contactId);
}
},
notFoundMessage:
'⚠️ DirectoryController not found, cannot refresh contacts.',
successMessage:
'✅ 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.',
successMessage: '✅ Notes refreshed from notification.',
);
}
static void _handleContactNoteModified(Map<String, dynamic> data) {
// Refresh both contacts and notes when a note is modified
_handleContactModified(data);
}
static void _handleBucketModified(Map<String, dynamic> data) {
_safeControllerUpdate<DirectoryController>(
onFound: (controller) => controller.fetchBuckets(),
notFoundMessage: '⚠️ DirectoryController not found, cannot refresh.',
successMessage: '✅ Buckets refreshed from notification.',
);
}
static void _handleBucketAssigned(Map<String, dynamic> data) {
_safeControllerUpdate<DirectoryController>(
onFound: (controller) => controller.fetchBuckets(),
notFoundMessage: '⚠️ DirectoryController not found, cannot refresh.',
successMessage: '✅ Bucket assignments refreshed from notification.',
);
}
/// ---------------------- DASHBOARD HANDLER ----------------------
static void _handleDashboardUpdate(Map<String, dynamic> data) {
_safeControllerUpdate<DashboardController>(
onFound: (controller) async {
final type = data['type'] ?? '';
switch (type) {
case 'attendance_updated':
await controller.fetchRoleWiseAttendance();
break;
case 'task_updated':
await controller.fetchDashboardTasks(
projectId: controller.projectController.selectedProjectId.value,
);
break;
case 'project_progress_update':
await controller.fetchProjectProgress();
break;
case 'Employee_Suspend':
final currentProjectId =
controller.projectController.selectedProjectId.value;
final projectIdsString = data['ProjectIds'] ?? '';
// 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);
}
break;
case 'Team_Modified':
final projectId = data['ProjectId'] ??
controller.projectController.selectedProjectId.value;
await controller.fetchDashboardTeams(projectId: projectId);
break;
case 'full_dashboard_refresh':
default:
await controller.refreshDashboard();
}
},
notFoundMessage: '⚠️ DashboardController not found, cannot refresh.',
successMessage: '✅ DashboardController refreshed from notification.',
);
}
/// ---------------------- UTILITY ----------------------
static void _safeControllerUpdate<T>({
required void Function(T controller) onFound,
required String notFoundMessage,
required String successMessage,
}) {
try {
final controller = Get.find<T>();
onFound(controller);
_logger.i(successMessage);
} catch (e) {
_logger.w(notFoundMessage);
}
}
}

View File

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

View File

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

View File

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

View File

@ -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: 'Marco', route: '/client/dashboard'),
));
bool isMobile = true;
try {

View File

@ -24,8 +24,8 @@ class AttendanceActionColors {
ButtonActions.rejected: Colors.orange,
ButtonActions.approved: Colors.green,
ButtonActions.requested: Colors.yellow,
ButtonActions.approve: Colors.blueAccent,
ButtonActions.reject: Colors.pink,
ButtonActions.approve: Colors.green,
ButtonActions.reject: Colors.red,
};
}

View File

@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class BaseBottomSheet extends StatelessWidget {
final String title;
final Widget child;
final VoidCallback onCancel;
final VoidCallback onSubmit;
final bool isSubmitting;
final String submitText;
final Color submitColor;
final IconData submitIcon;
final bool showButtons;
final Widget? bottomContent;
const BaseBottomSheet({
super.key,
required this.title,
required this.child,
required this.onCancel,
required this.onSubmit,
this.isSubmitting = false,
this.submitText = 'Submit',
this.submitColor = Colors.indigo,
this.submitIcon = Icons.check_circle_outline,
this.showButtons = true,
this.bottomContent,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
return SingleChildScrollView(
padding: mediaQuery.viewInsets,
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 12,
offset: Offset(0, -2),
),
],
),
child: SafeArea(
// 👈 prevents overlap with nav bar
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MySpacing.height(5),
Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
),
MySpacing.height(12),
MyText.titleLarge(title, fontWeight: 700),
MySpacing.height(12),
child,
MySpacing.height(12),
if (showButtons) ...[
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.close, color: Colors.white),
label: MyText.bodyMedium(
"Cancel",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: isSubmitting ? null : onSubmit,
icon: Icon(submitIcon, color: Colors.white),
label: MyText.bodyMedium(
isSubmitting ? "Submitting..." : submitText,
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: submitColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
],
),
if (bottomContent != null) ...[
MySpacing.height(12),
bottomContent!,
],
],
],
),
),
),
),
),
);
}
}

View File

@ -1,11 +1,10 @@
import 'package:intl/intl.dart';
import 'package:marco/helpers/services/app_logger.dart';
class DateTimeUtils {
static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) {
/// Converts a UTC datetime string to local time and formats it.
static String convertUtcToLocal(String utcTimeString,
{String format = 'dd-MM-yyyy'}) {
try {
logSafe('convertUtcToLocal: input="$utcTimeString", format="$format"');
final parsed = DateTime.parse(utcTimeString);
final utcDateTime = DateTime.utc(
parsed.year,
@ -17,22 +16,35 @@ class DateTimeUtils {
parsed.millisecond,
parsed.microsecond,
);
logSafe('Parsed (assumed UTC): $utcDateTime');
final localDateTime = utcDateTime.toLocal();
logSafe('Converted to Local: $localDateTime');
final formatted = _formatDateTime(localDateTime, format: format);
logSafe('Formatted Local Time: $formatted');
return formatted;
} catch (e, stackTrace) {
logSafe('DateTime conversion failed: $e', error: e, stackTrace: stackTrace);
return _formatDateTime(localDateTime, format: format);
} catch (e) {
return 'Invalid Date';
}
}
static String _formatDateTime(DateTime dateTime, {String format = 'dd-MM-yyyy'}) {
/// Public utility for formatting any DateTime.
static String formatDate(DateTime date, String format) {
try {
return DateFormat(format).format(date);
} catch (e) {
return 'Invalid Date';
}
}
/// Parses a date string using the given format.
static DateTime? parseDate(String dateString, String format) {
try {
return DateFormat(format).parse(dateString);
} catch (e) {
return null;
}
}
/// Internal formatter with default format.
static String _formatDateTime(DateTime dateTime,
{String format = 'dd-MM-yyyy'}) {
return DateFormat(format).format(dateTime);
}
}

View File

@ -1,16 +1,120 @@
/// Contains all role, permission, and entity UUIDs used for access control across the application.
class Permissions {
// ------------------- Project Management ------------------------------
/// Permission to manage master data (like dropdowns, configurations)
static const String manageMaster = "588a8824-f924-4955-82d8-fc51956cf323";
/// Permission to create, edit, delete projects
static const String manageProject = "172fc9b6-755b-4f62-ab26-55c34a330614";
/// Permission to view list of all projects
static const String viewProjects = "6ea44136-987e-44ba-9e5d-1cf8f5837ebc";
/// Permission to assign employees to a project
static const String assignToProject = "b94802ce-0689-4643-9e1d-11c86950c35b";
// ------------------- Employee Management -----------------------------
/// Permission to manage employee records
static const String manageEmployees = "a97d366a-c2bb-448d-be93-402bd2324566";
static const String manageProjectInfra ="f2aee20a-b754-4537-8166-f9507b44585b";
static const String viewProjectInfra = "c7b68e33-72f0-474f-bd96-77636427ecc8";
static const String regularizeAttendance ="57802c4a-00aa-4a1f-a048-fd2f70dd44b6";
static const String assignToProject = "fbd213e0-0250-46f1-9f5f-4b2a1e6e76a3";
static const String infrastructure = "9666de86-d7c7-4d3d-acaa-fcd6d6b81f3c";
/// Permission to view all employees
static const String viewAllEmployees = "60611762-7f8a-4fb5-b53f-b1139918796b";
/// Permission to view only team members (subordinate employees)
static const String viewTeamMembers = "b82d2b7e-0d52-45f3-997b-c008ea460e7f";
// ------------------- Project Infrastructure --------------------------
/// Permission to manage project infrastructure (e.g., site details)
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";
// ------------------- Task Management ---------------------------------
/// Permission to create and manage tasks
static const String manageTask = "08752f33-3b29-4816-b76b-ea8a968ed3c5";
/// Permission to approve tasks
static const String approveTask = "db4e40c5-2ba9-4b6d-b8a6-a16a250ff99c";
/// Permission to view task lists and details
static const String viewTask = "9fcc5f87-25e3-4846-90ac-67a71ab92e3c";
/// Permission to assign tasks for reporting
static const String assignReportTask = "6a32379b-8b3f-49a6-8c48-4b7ac1b55dc2";
// ------------------- Directory Roles ---------------------------------
/// Admin-level directory access
static const String directoryAdmin = "4286a13b-bb40-4879-8c6d-18e9e393beda";
/// Manager-level directory access
static const String directoryManager = "62668630-13ce-4f52-a0f0-db38af2230c5";
/// Basic directory user access
static const String directoryUser = "0f919170-92d4-4337-abd3-49b66fc871bb";
// ------------------- Expense Permissions -----------------------------
/// View only own expenses
static const String expenseViewSelf = "385be49f-8fde-440e-bdbc-3dffeb8dd116";
/// View all employee expenses
static const String expenseViewAll = "01e06444-9ca7-4df4-b900-8c3fa051b92f";
/// Create/upload new expenses
static const String expenseUpload = "0f57885d-bcb2-4711-ac95-d841ace6d5a7";
/// Review submitted expenses
static const String expenseReview = "1f4bda08-1873-449a-bb66-3e8222bd871b";
/// Approve or reject expenses
static const String expenseApprove = "eaafdd76-8aac-45f9-a530-315589c6deca";
/// Process expenses for payment or final action
static const String expenseProcess = "ea5a1529-4ee8-4828-80ea-0e23c9d4dd11";
/// Full access to manage all expense operations
static const String expenseManage = "ea5a1529-4ee8-4828-80ea-0e23c9d4dd11";
/// ID used to track expenses in "Draft" status
static const String expenseDraft = "297e0d8f-f668-41b5-bfea-e03b354251c8";
/// List of user IDs who rejected the expense (used for audit trail)
static const List<String> expenseRejectedBy = [
"d1ee5eec-24b6-4364-8673-a8f859c60729",
"965eda62-7907-4963-b4a1-657fb0b2724b",
];
// ------------------- Application Roles -------------------------------
/// Application role ID for users with full expense management rights
static const String expenseManagement = "a4e25142-449b-4334-a6e5-22f70e4732d7";
// ------------------- Document Entities -------------------------------
/// Entity ID for project documents
static const String projectEntity = "c8fe7115-aa27-43bc-99f4-7b05fabe436e";
/// Entity ID for employee documents
static const String employeeEntity = "dbb9555a-7a0c-40f2-a9ed-f0463f1ceed7";
// ------------------- Document Permissions ----------------------------
/// Permission to view documents
static const String viewDocument = "71189504-f1c8-4ca5-8db6-810497be2854";
/// Permission to upload documents
static const String uploadDocument = "3f6d1f67-6fa5-4b7c-b17b-018d4fe4aab8";
/// Permission to modify documents
static const String modifyDocument = "c423fd81-6273-4b9d-bb5e-76a0fb343833";
/// Permission to delete documents
static const String deleteDocument = "40863a13-5a66-469d-9b48-135bc5dbf486";
/// Permission to download documents
static const String downloadDocument = "404373d0-860f-490e-a575-1c086ffbce1d";
/// Permission to verify documents
static const String verifyDocument = "13a1f30f-38d1-41bf-8e7a-b75189aab8e0";
}

View File

@ -0,0 +1,271 @@
// lib/utils/validators.dart
import 'package:flutter/services.dart';
/// Common validators for Indian IDs, payments, and typical form fields.
class Validators {
// -----------------------------
// Regexes (compiled once)
// -----------------------------
static final RegExp _panRegex = RegExp(r'^[A-Z]{5}[0-9]{4}[A-Z]$');
// GSTIN: 2-digit/valid state code, PAN, entity code (1-9A-Z), 'Z', checksum (0-9A-Z)
static final RegExp _gstRegex = RegExp(
r'^(0[1-9]|1[0-9]|2[0-9]|3[0-7])[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$',
);
// Aadhaar digits only
static final RegExp _aadhaarRegex = RegExp(r'^[2-9]\d{11}$');
// Name (letters + spaces + dots + hyphen/apostrophe)
static final RegExp _nameRegex = RegExp(r"^[A-Za-z][A-Za-z .'\-]{1,49}$");
// Email (generic)
static final RegExp _emailRegex =
RegExp(r"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$");
// Indian mobile
static final RegExp _mobileRegex = RegExp(r'^[6-9]\d{9}$');
// Pincode (India: 6 digits starting 19)
static final RegExp _pincodeRegex = RegExp(r'^[1-9][0-9]{5}$');
// IFSC (4 letters + 0 + 6 alphanumeric)
static final RegExp _ifscRegex = RegExp(r'^[A-Z]{4}0[A-Z0-9]{6}$');
// Bank account number (918 digits)
static final RegExp _bankAccountRegex = RegExp(r'^\d{9,18}$');
// UPI ID (name@bank, simple check)
static final RegExp _upiRegex =
RegExp(r'^[\w.\-]{2,}@[\w]{2,}$', caseSensitive: false);
// Strong password (8+ chars, upper, lower, digit, special)
static final RegExp _passwordRegex =
RegExp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$');
// Date dd/mm/yyyy (basic validation)
static final RegExp _dateRegex =
RegExp(r'^([0-2][0-9]|3[0-1])/(0[1-9]|1[0-2])/[0-9]{4}$');
// URL
static final RegExp _urlRegex = RegExp(
r'^(https?:\/\/)?([a-zA-Z0-9.-]+)\.[a-zA-Z]{2,}(:\d+)?(\/\S*)?$');
// Transaction ID (alphanumeric, dashes/underscores, 836 chars)
static final RegExp _transactionIdRegex =
RegExp(r'^[A-Za-z0-9\-_]{8,36}$');
// -----------------------------
// PAN
// -----------------------------
static bool isValidPAN(String? input) {
if (input == null) return false;
return _panRegex.hasMatch(input.trim().toUpperCase());
}
// -----------------------------
// GSTIN
// -----------------------------
static bool isValidGSTIN(String? input) {
if (input == null) return false;
return _gstRegex.hasMatch(_compact(input).toUpperCase());
}
// -----------------------------
// Aadhaar
// -----------------------------
static bool isValidAadhaar(String? input, {bool enforceChecksum = true}) {
if (input == null) return false;
final a = _digitsOnly(input);
if (!_aadhaarRegex.hasMatch(a)) return false;
return enforceChecksum ? _verhoeffValidate(a) : true;
}
// -----------------------------
// Mobile
// -----------------------------
static bool isValidIndianMobile(String? input) {
if (input == null) return false;
final s = _digitsOnly(input.replaceFirst(RegExp(r'^(?:\+?91|0)'), ''));
return _mobileRegex.hasMatch(s);
}
// -----------------------------
// Email
// -----------------------------
static bool isValidEmail(String? input, {bool gmailOnly = false}) {
if (input == null) return false;
final e = input.trim();
if (!_emailRegex.hasMatch(e)) return false;
if (!gmailOnly) return true;
final domain = e.split('@').last.toLowerCase();
return domain == 'gmail.com' || domain == 'googlemail.com';
}
static bool isValidGmail(String? input) =>
isValidEmail(input, gmailOnly: true);
// -----------------------------
// Name
// -----------------------------
static bool isValidName(String? input, {int minLen = 2, int maxLen = 50}) {
if (input == null) return false;
final s = input.trim();
if (s.length < minLen || s.length > maxLen) return false;
return _nameRegex.hasMatch(s);
}
// -----------------------------
// Transaction ID
// -----------------------------
static bool isValidTransactionId(String? input) {
if (input == null) return false;
return _transactionIdRegex.hasMatch(input.trim());
}
// -----------------------------
// Other fields
// -----------------------------
static bool isValidPincode(String? input) =>
input != null && _pincodeRegex.hasMatch(input.trim());
static bool isValidIFSC(String? input) =>
input != null && _ifscRegex.hasMatch(input.trim().toUpperCase());
static bool isValidBankAccount(String? input) =>
input != null && _bankAccountRegex.hasMatch(_digitsOnly(input));
static bool isValidUPI(String? input) =>
input != null && _upiRegex.hasMatch(input.trim());
static bool isValidPassword(String? input) =>
input != null && _passwordRegex.hasMatch(input.trim());
static bool isValidDate(String? input) =>
input != null && _dateRegex.hasMatch(input.trim());
static bool isValidURL(String? input) =>
input != null && _urlRegex.hasMatch(input.trim());
// -----------------------------
// Numbers
// -----------------------------
static bool isInt(String? input) =>
input != null && int.tryParse(input.trim()) != null;
static bool isDouble(String? input) =>
input != null && double.tryParse(input.trim()) != null;
static bool isNumeric(String? input) => isInt(input) || isDouble(input);
static bool isInRange(num? value,
{num? min, num? max, bool inclusive = true}) {
if (value == null) return false;
if (min != null && (inclusive ? value < min : value <= min)) return false;
if (max != null && (inclusive ? value > max : value >= max)) return false;
return true;
}
// -----------------------------
// Flutter-friendly validator lambdas (return null when valid)
// -----------------------------
static String? requiredField(String? v, {String fieldName = 'This field'}) =>
(v == null || v.trim().isEmpty) ? '$fieldName is required' : null;
static String? panValidator(String? v) =>
isValidPAN(v) ? null : 'Enter a valid PAN (e.g., ABCDE1234F)';
static String? gstValidator(String? v, {bool optional = false}) {
if (optional && (v == null || v.trim().isEmpty)) return null;
return isValidGSTIN(v) ? null : 'Enter a valid GSTIN';
}
static String? aadhaarValidator(String? v) =>
isValidAadhaar(v) ? null : 'Enter a valid Aadhaar (12 digits)';
static String? mobileValidator(String? v) =>
isValidIndianMobile(v) ? null : 'Enter a valid 10-digit mobile';
static String? emailValidator(String? v, {bool gmailOnly = false}) =>
isValidEmail(v, gmailOnly: gmailOnly)
? null
: gmailOnly
? 'Enter a valid Gmail address'
: 'Enter a valid email address';
static String? nameValidator(String? v, {int minLen = 2, int maxLen = 50}) =>
isValidName(v, minLen: minLen, maxLen: maxLen)
? null
: 'Enter a valid name ($minLen$maxLen chars)';
static String? transactionIdValidator(String? v) =>
isValidTransactionId(v)
? null
: 'Enter a valid Transaction ID (836 chars, letters/numbers)';
static String? pincodeValidator(String? v) =>
isValidPincode(v) ? null : 'Enter a valid 6-digit pincode';
static String? ifscValidator(String? v) =>
isValidIFSC(v) ? null : 'Enter a valid IFSC code';
static String? bankAccountValidator(String? v) =>
isValidBankAccount(v) ? null : 'Enter a valid bank account (918 digits)';
static String? upiValidator(String? v) =>
isValidUPI(v) ? null : 'Enter a valid UPI ID';
static String? passwordValidator(String? v) =>
isValidPassword(v)
? null
: 'Password must be 8+ chars with upper, lower, digit, special';
static String? dateValidator(String? v) =>
isValidDate(v) ? null : 'Enter date in dd/mm/yyyy format';
static String? urlValidator(String? v) =>
isValidURL(v) ? null : 'Enter a valid URL';
// -----------------------------
// Helpers
// -----------------------------
static String _digitsOnly(String s) => s.replaceAll(RegExp(r'\D'), '');
static String _compact(String s) => s.replaceAll(RegExp(r'\s'), '');
// -----------------------------
// Verhoeff checksum (for Aadhaar)
// -----------------------------
static const List<List<int>> _verhoeffD = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
[2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
[4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
[5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
[7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
[8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
];
static const List<List<int>> _verhoeffP = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
[5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
[9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
[4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
[2, 7, 9, 3, 8, 0, 5, 4, 1, 6],
[7, 0, 4, 6, 9, 1, 2, 3, 5, 8],
];
static bool _verhoeffValidate(String numStr) {
int c = 0;
final rev = numStr.split('').reversed.map(int.parse).toList();
for (int i = 0; i < rev.length; i++) {
c = _verhoeffD[c][_verhoeffP[(i % 8)][rev[i]]];
}
return c == 0;
}
}
/// Common input formatters/masks useful in TextFields.
class InputFormatters {
static final digitsOnly = FilteringTextInputFormatter.digitsOnly;
static final upperAlnum =
FilteringTextInputFormatter.allow(RegExp(r'[A-Z0-9]'));
static final upperLetters =
FilteringTextInputFormatter.allow(RegExp(r'[A-Z]'));
static final name =
FilteringTextInputFormatter.allow(RegExp(r"[A-Za-z .'\-]"));
static final alnumWithSpace =
FilteringTextInputFormatter.allow(RegExp(r"[A-Za-z0-9 ]"));
static LengthLimitingTextInputFormatter maxLen(int n) =>
LengthLimitingTextInputFormatter(n);
}

View File

@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill;
import 'package:marco/helpers/widgets/my_text.dart';
class CommentEditorCard extends StatelessWidget {
class CommentEditorCard extends StatefulWidget {
final quill.QuillController controller;
final VoidCallback onCancel;
final Future<void> Function(quill.QuillController controller) onSave;
@ -13,13 +14,31 @@ class CommentEditorCard extends StatelessWidget {
required this.onSave,
});
@override
State<CommentEditorCard> createState() => _CommentEditorCardState();
}
class _CommentEditorCardState extends State<CommentEditorCard> {
bool _isSubmitting = false;
Future<void> _handleSave() async {
if (_isSubmitting) return;
setState(() => _isSubmitting = true);
try {
await widget.onSave(widget.controller);
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
quill.QuillSimpleToolbar(
controller: controller,
controller: widget.controller,
configurations: const quill.QuillSimpleToolbarConfigurations(
showBoldButton: true,
showItalicButton: true,
@ -48,7 +67,7 @@ class CommentEditorCard extends StatelessWidget {
multiRowsDisplay: false,
),
),
const SizedBox(height: 38),
const SizedBox(height: 24),
Container(
height: 140,
padding: const EdgeInsets.all(8),
@ -58,7 +77,7 @@ class CommentEditorCard extends StatelessWidget {
color: const Color(0xFFFDFDFD),
),
child: quill.QuillEditor.basic(
controller: controller,
controller: widget.controller,
configurations: const quill.QuillEditorConfigurations(
autoFocus: true,
expands: false,
@ -66,32 +85,50 @@ class CommentEditorCard extends StatelessWidget {
),
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
children: [
OutlinedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.close, size: 18),
label: const Text("Cancel"),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.grey[700],
const SizedBox(height: 16),
// 👇 Buttons same as BaseBottomSheet
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isSubmitting ? null : widget.onCancel,
icon: const Icon(Icons.close, color: Colors.white),
label: MyText.bodyMedium(
"Cancel",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
ElevatedButton.icon(
onPressed: () => onSave(controller),
icon: const Icon(Icons.save, size: 18),
label: const Text("Save"),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: _isSubmitting ? null : _handleSave,
icon: const Icon(Icons.check_circle_outline, color: Colors.white),
label: MyText.bodyMedium(
_isSubmitting ? "Submitting..." : "Submit",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
],
),
)
),
],
),
],
);
}

View File

@ -30,8 +30,9 @@ class Avatar extends StatelessWidget {
paddingAll: 0,
color: bgColor,
child: Center(
child: MyText.labelSmall(
child: MyText(
initials,
fontSize: size * 0.45, // 👈 scales with avatar size
fontWeight: 600,
color: textColor,
),

View File

@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/controller/project_controller.dart';
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
final VoidCallback? onBackPressed;
const CustomAppBar({
super.key,
required this.title,
this.onBackPressed,
});
@override
Widget build(BuildContext context) {
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: Container(
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 0.5,
offset: const Offset(0, 0.5),
)
],
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: onBackPressed ?? Get.back,
splashRadius: 24,
),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge(
title,
fontWeight: 700,
color: Colors.black,
),
const SizedBox(height: 2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
const SizedBox(width: 4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
),
),
);
}
@override
Size get preferredSize => const Size.fromHeight(72);
}

View File

@ -0,0 +1,462 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class AttendanceDashboardChart extends StatelessWidget {
AttendanceDashboardChart({Key? key}) : super(key: key);
final DashboardController _controller = Get.find<DashboardController>();
static const List<Color> _flatColors = [
Color(0xFFE57373), // Red 300
Color(0xFF64B5F6), // Blue 300
Color(0xFF81C784), // Green 300
Color(0xFFFFB74D), // Orange 300
Color(0xFFBA68C8), // Purple 300
Color(0xFFFF8A65), // Deep Orange 300
Color(0xFF4DB6AC), // Teal 300
Color(0xFFA1887F), // Brown 400
Color(0xFFDCE775), // Lime 300
Color(0xFF9575CD), // Deep Purple 300
Color(0xFF7986CB), // Indigo 300
Color(0xFFAED581), // Light Green 300
Color(0xFFFF7043), // Deep Orange 400
Color(0xFF4FC3F7), // Light Blue 300
Color(0xFFFFD54F), // Amber 300
Color(0xFF90A4AE), // Blue Grey 300
Color(0xFFE573BB), // Pink 300
Color(0xFF81D4FA), // Light Blue 200
Color(0xFFBCAAA4), // Brown 300
Color(0xFFA5D6A7), // Green 300
Color(0xFFCE93D8), // Purple 200
Color(0xFFFF8A65), // Deep Orange 300 (repeat to fill)
Color(0xFF80CBC4), // Teal 200
Color(0xFFFFF176), // Yellow 300
Color(0xFF90CAF9), // Blue 200
Color(0xFFE0E0E0), // Grey 300
Color(0xFFF48FB1), // Pink 200
Color(0xFFA1887F), // Brown 400 (repeat)
Color(0xFFB0BEC5), // Blue Grey 200
Color(0xFF81C784), // Green 300 (repeat)
Color(0xFFFFB74D), // Orange 300 (repeat)
Color(0xFF64B5F6), // Blue 300 (repeat)
];
Color _getRoleColor(String role) {
final index = role.hashCode.abs() % _flatColors.length;
return _flatColors[index];
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
return Obx(() {
final isChartView = _controller.attendanceIsChartView.value;
final selectedRange = _controller.attendanceSelectedRange.value;
final filteredData = _getFilteredData();
return Container(
decoration: _containerDecoration,
padding: EdgeInsets.symmetric(
vertical: 16,
horizontal: screenWidth < 600 ? 8 : 20,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Header(
selectedRange: selectedRange,
isChartView: isChartView,
screenWidth: screenWidth,
onToggleChanged: (isChart) =>
_controller.attendanceIsChartView.value = isChart,
onRangeChanged: _controller.updateAttendanceRange,
),
const SizedBox(height: 12),
Expanded(
child: filteredData.isEmpty
? _NoDataMessage()
: isChartView
? _AttendanceChart(
data: filteredData, getRoleColor: _getRoleColor)
: _AttendanceTable(
data: filteredData,
getRoleColor: _getRoleColor,
screenWidth: screenWidth),
),
],
),
);
});
}
BoxDecoration get _containerDecoration => BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.05),
blurRadius: 6,
spreadRadius: 1,
offset: const Offset(0, 2),
),
],
);
List<Map<String, dynamic>> _getFilteredData() {
final now = DateTime.now();
final daysBack = _controller.getAttendanceDays();
return _controller.roleWiseData.where((entry) {
final date = DateTime.parse(entry['date'] as String);
return date.isAfter(now.subtract(Duration(days: daysBack))) &&
!date.isAfter(now);
}).toList();
}
}
// Header
class _Header extends StatelessWidget {
const _Header({
Key? key,
required this.selectedRange,
required this.isChartView,
required this.screenWidth,
required this.onToggleChanged,
required this.onRangeChanged,
}) : super(key: key);
final String selectedRange;
final bool isChartView;
final double screenWidth;
final ValueChanged<bool> onToggleChanged;
final ValueChanged<String> onRangeChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium('Attendance Overview', fontWeight: 700),
const SizedBox(height: 2),
MyText.bodySmall('Role-wise present count',
color: Colors.grey),
],
),
),
ToggleButtons(
borderRadius: BorderRadius.circular(5),
borderColor: Colors.grey,
fillColor: Colors.blueAccent.withOpacity(0.15),
selectedBorderColor: Colors.blueAccent,
selectedColor: Colors.blueAccent,
color: Colors.grey,
constraints: BoxConstraints(
minHeight: 30,
minWidth: screenWidth < 400 ? 28 : 36,
),
isSelected: [isChartView, !isChartView],
onPressed: (index) => onToggleChanged(index == 0),
children: const [
Icon(Icons.bar_chart_rounded, size: 15),
Icon(Icons.table_chart, size: 15),
],
),
],
),
const SizedBox(height: 8),
Row(
children: ["7D", "15D", "30D"]
.map(
(label) => Padding(
padding: const EdgeInsets.only(right: 4),
child: ChoiceChip(
label: Text(label, style: const TextStyle(fontSize: 12)),
padding:
const EdgeInsets.symmetric(horizontal: 5, vertical: 0),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
selected: selectedRange == label,
onSelected: (_) => onRangeChanged(label),
selectedColor: Colors.blueAccent.withOpacity(0.15),
backgroundColor: Colors.grey.shade200,
labelStyle: TextStyle(
color: selectedRange == label
? Colors.blueAccent
: Colors.black87,
fontWeight: selectedRange == label
? FontWeight.w600
: FontWeight.normal,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
side: BorderSide(
color: selectedRange == label
? Colors.blueAccent
: Colors.grey.shade300,
),
),
),
),
)
.toList(),
),
],
);
}
}
// No Data
class _NoDataMessage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
height: 180,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.info_outline, color: Colors.grey.shade400, size: 48),
const SizedBox(height: 10),
MyText.bodyMedium(
'No attendance data available for this range.',
textAlign: TextAlign.center,
color: Colors.grey.shade500,
),
],
),
),
);
}
}
// Chart
class _AttendanceChart extends StatelessWidget {
const _AttendanceChart({
Key? key,
required this.data,
required this.getRoleColor,
}) : super(key: key);
final List<Map<String, dynamic>> data;
final Color Function(String role) getRoleColor;
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('d MMM');
final uniqueDates = data
.map((e) => DateTime.parse(e['date'] as String))
.toSet()
.toList()
..sort();
final filteredDates = uniqueDates.map(dateFormat.format).toList();
final filteredRoles = data.map((e) => e['role'] as String).toSet().toList();
final allZero = filteredRoles.every((role) {
return data
.where((entry) => entry['role'] == role)
.every((entry) => (entry['present'] ?? 0) == 0);
});
if (allZero) {
return Container(
height: 600,
child: const Center(
child: Text(
'No attendance data for the selected range.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
),
),
);
}
final formattedMap = {
for (var e in data)
'${e['role']}_${dateFormat.format(DateTime.parse(e['date'] as String))}':
e['present'],
};
final rolesWithData = filteredRoles.where((role) {
return data
.any((entry) => entry['role'] == role && (entry['present'] ?? 0) > 0);
}).toList();
return Container(
height: 600,
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
),
child: SfCartesianChart(
tooltipBehavior: TooltipBehavior(enable: true, shared: true),
legend: Legend(isVisible: true, position: LegendPosition.bottom),
primaryXAxis: CategoryAxis(labelRotation: 45),
primaryYAxis: NumericAxis(minimum: 0, interval: 1),
series: rolesWithData.map((role) {
final seriesData = filteredDates
.map((date) {
final key = '${role}_$date';
return {'date': date, 'present': formattedMap[key] ?? 0};
})
.where((d) => (d['present'] ?? 0) > 0)
.toList();
return StackedColumnSeries<Map<String, dynamic>, String>(
dataSource: seriesData,
xValueMapper: (d, _) => d['date'],
yValueMapper: (d, _) => d['present'],
name: role,
color: getRoleColor(role),
dataLabelSettings: DataLabelSettings(
isVisible: true,
builder: (dynamic data, _, __, ___, ____) {
return (data['present'] ?? 0) > 0
? Text(
NumberFormat.decimalPattern().format(data['present']),
style: const TextStyle(fontSize: 11),
)
: const SizedBox.shrink();
},
),
);
}).toList(),
),
);
}
}
// Table
class _AttendanceTable extends StatelessWidget {
const _AttendanceTable({
Key? key,
required this.data,
required this.getRoleColor,
required this.screenWidth,
}) : super(key: key);
final List<Map<String, dynamic>> data;
final Color Function(String role) getRoleColor;
final double screenWidth;
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('d MMM');
final uniqueDates = data
.map((e) => DateTime.parse(e['date'] as String))
.toSet()
.toList()
..sort();
final filteredDates = uniqueDates.map(dateFormat.format).toList();
final filteredRoles = data.map((e) => e['role'] as String).toSet().toList();
final allZero = filteredRoles.every((role) {
return data
.where((entry) => entry['role'] == role)
.every((entry) => (entry['present'] ?? 0) == 0);
});
if (allZero) {
return Container(
height: 300,
child: const Center(
child: Text(
'No attendance data for the selected range.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
),
),
);
}
final formattedMap = {
for (var e in data)
'${e['role']}_${dateFormat.format(DateTime.parse(e['date'] as String))}':
e['present'],
};
return Container(
height: 300,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(5),
),
child: Scrollbar(
thumbVisibility: true,
trackVisibility: true,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints:
BoxConstraints(minWidth: MediaQuery.of(context).size.width),
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: DataTable(
columnSpacing: 20,
headingRowHeight: 44,
headingRowColor: MaterialStateProperty.all(
Colors.blueAccent.withOpacity(0.08)),
headingTextStyle: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.black87),
columns: [
const DataColumn(label: Text('Role')),
...filteredDates.map((d) => DataColumn(label: Text(d))),
],
rows: filteredRoles.map((role) {
return DataRow(
cells: [
DataCell(
_RolePill(role: role, color: getRoleColor(role))),
...filteredDates.map((date) {
final key = '${role}_$date';
return DataCell(
Text(
NumberFormat.decimalPattern()
.format(formattedMap[key] ?? 0),
style: const TextStyle(fontSize: 13),
),
);
}),
],
);
}).toList(),
),
),
),
),
),
);
}
}
class _RolePill extends StatelessWidget {
const _RolePill({Key? key, required this.role, required this.color})
: super(key: key);
final String role;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(5),
),
child: MyText.labelSmall(role, fontWeight: 500),
);
}
}

View File

@ -0,0 +1,393 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
// Assuming these exist in the project
import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/widgets/my_card.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class DashboardOverviewWidgets {
static final DashboardController dashboardController =
Get.find<DashboardController>();
// Text styles
static const _titleStyle = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.black87,
letterSpacing: 0.2,
);
static const _subtitleStyle = TextStyle(
fontSize: 12,
color: Colors.black54,
letterSpacing: 0.1,
);
static const _metricStyle = TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: Colors.black87,
);
static const _percentStyle = TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.black87,
);
static final NumberFormat _comma = NumberFormat.decimalPattern();
// Colors
static const Color _primaryA = Color(0xFF1565C0); // Blue
static const Color _accentA = Color(0xFF2E7D32); // Green
static const Color _warnA = Color(0xFFC62828); // Red
static const Color _muted = Color(0xFF9E9E9E); // Grey
static const Color _hint = Color(0xFFBDBDBD); // Light Grey
static const Color _bgSoft = Color(0xFFF7F8FA); // Light background
// --- TEAMS OVERVIEW ---
static Widget teamsOverview() {
return Obx(() {
if (dashboardController.isTeamsLoading.value) {
return _skeletonCard(title: "Teams");
}
final total = dashboardController.totalEmployees.value;
final inToday = dashboardController.inToday.value.clamp(0, total);
final percent = total > 0 ? inToday / total : 0.0;
final hasData = total > 0;
final data = hasData
? [
_ChartData('In Today', inToday.toDouble(), _accentA),
_ChartData('Total', total.toDouble(), _muted),
]
: [
_ChartData('No Data', 1.0, _hint),
];
return _MetricCard(
icon: Icons.group,
iconColor: _primaryA,
title: "Teams",
subtitle: hasData ? "Attendance today" : "Awaiting data",
chart: _SemiDonutChart(
percentLabel: "${(percent * 100).toInt()}%",
data: data,
startAngle: 270,
endAngle: 90,
showLegend: false,
),
footer: _SingleColumnKpis(
stats: {
"In Today": _comma.format(inToday),
"Total": _comma.format(total),
},
colors: {
"In Today": _accentA,
"Total": _muted,
},
),
);
});
}
// --- TASKS OVERVIEW ---
static Widget tasksOverview() {
return Obx(() {
if (dashboardController.isTasksLoading.value) {
return _skeletonCard(title: "Tasks");
}
final total = dashboardController.totalTasks.value;
final completed =
dashboardController.completedTasks.value.clamp(0, total);
final remaining = (total - completed).clamp(0, total);
final percent = total > 0 ? completed / total : 0.0;
final hasData = total > 0;
final data = hasData
? [
_ChartData('Completed', completed.toDouble(), _primaryA),
_ChartData('Remaining', remaining.toDouble(), _warnA),
]
: [
_ChartData('No Data', 1.0, _hint),
];
return _MetricCard(
icon: Icons.task_alt,
iconColor: _primaryA,
title: "Tasks",
subtitle: hasData ? "Completion status" : "Awaiting data",
chart: _SemiDonutChart(
percentLabel: "${(percent * 100).toInt()}%",
data: data,
startAngle: 270,
endAngle: 90,
showLegend: false,
),
footer: _SingleColumnKpis(
stats: {
"Completed": _comma.format(completed),
"Remaining": _comma.format(remaining),
},
colors: {
"Completed": _primaryA,
"Remaining": _warnA,
},
),
);
});
}
// Skeleton card
static Widget _skeletonCard({required String title}) {
return LayoutBuilder(builder: (context, constraints) {
final width = constraints.maxWidth.clamp(220.0, 480.0);
return SizedBox(
width: width,
child: MyCard(
borderRadiusAll: 5,
paddingAll: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Skeleton.line(width: 120, height: 16),
MySpacing.height(12),
_Skeleton.line(width: 80, height: 12),
MySpacing.height(16),
_Skeleton.block(height: 120),
MySpacing.height(16),
_Skeleton.line(width: double.infinity, height: 12),
],
),
),
);
});
}
}
// --- METRIC CARD with chart on left, stats on right ---
class _MetricCard extends StatelessWidget {
final IconData icon;
final Color iconColor;
final String title;
final String subtitle;
final Widget chart;
final Widget footer;
const _MetricCard({
required this.icon,
required this.iconColor,
required this.title,
required this.subtitle,
required this.chart,
required this.footer,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final maxW = constraints.maxWidth;
final clampedW = maxW.clamp(260.0, 560.0);
final dense = clampedW < 340;
return SizedBox(
width: clampedW,
child: MyCard(
borderRadiusAll: 5,
paddingAll: dense ? 14 : 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: icon + title + subtitle
Row(
children: [
_IconBadge(icon: icon, color: iconColor),
MySpacing.width(10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText(title,
style: DashboardOverviewWidgets._titleStyle),
MySpacing.height(2),
MyText(subtitle,
style: DashboardOverviewWidgets._subtitleStyle),
MySpacing.height(12),
],
),
),
],
),
// Body: chart left, stats right
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: SizedBox(
height: dense ? 120 : 150,
child: chart,
),
),
MySpacing.width(12),
Expanded(
flex: 1,
child: footer, // Stats stacked vertically
),
],
),
],
),
),
);
});
}
}
// --- SINGLE COLUMN KPIs (stacked vertically) ---
class _SingleColumnKpis extends StatelessWidget {
final Map<String, String> stats;
final Map<String, Color>? colors;
const _SingleColumnKpis({required this.stats, this.colors});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: stats.entries.map((entry) {
final color = colors != null && colors!.containsKey(entry.key)
? colors![entry.key]!
: DashboardOverviewWidgets._metricStyle.color;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText(entry.key, style: DashboardOverviewWidgets._subtitleStyle),
MyText(entry.value,
style: DashboardOverviewWidgets._metricStyle
.copyWith(color: color)),
],
),
);
}).toList(),
);
}
}
// --- SEMI DONUT CHART ---
class _SemiDonutChart extends StatelessWidget {
final String percentLabel;
final List<_ChartData> data;
final int startAngle;
final int endAngle;
final bool showLegend;
const _SemiDonutChart({
required this.percentLabel,
required this.data,
required this.startAngle,
required this.endAngle,
this.showLegend = false,
});
bool get _hasData =>
data.isNotEmpty &&
data.any((d) => d.color != DashboardOverviewWidgets._hint);
@override
Widget build(BuildContext context) {
final chartData = _hasData
? data
: [_ChartData('No Data', 1.0, DashboardOverviewWidgets._hint)];
return SfCircularChart(
margin: EdgeInsets.zero,
centerY: '65%', // pull donut up
legend: Legend(isVisible: showLegend && _hasData),
annotations: <CircularChartAnnotation>[
CircularChartAnnotation(
widget: Center(
child: MyText(percentLabel, style: DashboardOverviewWidgets._percentStyle),
),
),
],
series: <DoughnutSeries<_ChartData, String>>[
DoughnutSeries<_ChartData, String>(
dataSource: chartData,
xValueMapper: (d, _) => d.category,
yValueMapper: (d, _) => d.value,
pointColorMapper: (d, _) => d.color,
startAngle: startAngle,
endAngle: endAngle,
radius: '80%',
innerRadius: '65%',
strokeWidth: 0,
dataLabelSettings: const DataLabelSettings(isVisible: false),
),
],
);
}
}
// --- ICON BADGE ---
class _IconBadge extends StatelessWidget {
final IconData icon;
final Color color;
const _IconBadge({required this.icon, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: DashboardOverviewWidgets._bgSoft,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: color, size: 22),
);
}
}
// --- SKELETON ---
class _Skeleton {
static Widget line({double width = double.infinity, double height = 14}) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
);
}
static Widget block({double height = 120}) {
return Container(
width: double.infinity,
height: height,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
);
}
}
// --- CHART DATA ---
class _ChartData {
final String category;
final double value;
final Color color;
_ChartData(this.category, this.value, this.color);
}

View File

@ -0,0 +1,354 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:marco/model/dashboard/project_progress_model.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class ProjectProgressChart extends StatelessWidget {
final List<ChartTaskData> data;
final DashboardController controller = Get.find<DashboardController>();
ProjectProgressChart({super.key, required this.data});
// ================= Flat Colors =================
static const List<Color> _flatColors = [
Color(0xFFE57373),
Color(0xFF64B5F6),
Color(0xFF81C784),
Color(0xFFFFB74D),
Color(0xFFBA68C8),
Color(0xFFFF8A65),
Color(0xFF4DB6AC),
Color(0xFFA1887F),
Color(0xFFDCE775),
Color(0xFF9575CD),
Color(0xFF7986CB),
Color(0xFFAED581),
Color(0xFFFF7043),
Color(0xFF4FC3F7),
Color(0xFFFFD54F),
Color(0xFF90A4AE),
Color(0xFFE573BB),
Color(0xFF81D4FA),
Color(0xFFBCAAA4),
Color(0xFFA5D6A7),
Color(0xFFCE93D8),
Color(0xFFFF8A65),
Color(0xFF80CBC4),
Color(0xFFFFF176),
Color(0xFF90CAF9),
Color(0xFFE0E0E0),
Color(0xFFF48FB1),
Color(0xFFA1887F),
Color(0xFFB0BEC5),
Color(0xFF81C784),
Color(0xFFFFB74D),
Color(0xFF64B5F6),
];
static final NumberFormat _commaFormatter = NumberFormat.decimalPattern();
Color _getTaskColor(String taskName) {
final index = taskName.hashCode % _flatColors.length;
return _flatColors[index];
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
return Obx(() {
final isChartView = controller.projectIsChartView.value;
final selectedRange = controller.projectSelectedRange.value;
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.04),
blurRadius: 6,
spreadRadius: 1,
offset: Offset(0, 2),
),
],
),
padding: EdgeInsets.symmetric(
vertical: 16,
horizontal: screenWidth < 600 ? 8 : 24,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(selectedRange, isChartView, screenWidth),
const SizedBox(height: 14),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) => AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: data.isEmpty
? _buildNoDataMessage()
: isChartView
? _buildChart(constraints.maxHeight)
: _buildTable(constraints.maxHeight, screenWidth),
),
),
),
],
),
);
});
}
Widget _buildHeader(
String selectedRange, bool isChartView, double screenWidth) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium('Project Progress', fontWeight: 700),
MyText.bodySmall('Planned vs Completed',
color: Colors.grey.shade700),
],
),
),
ToggleButtons(
borderRadius: BorderRadius.circular(5),
borderColor: Colors.grey,
fillColor: Colors.blueAccent.withOpacity(0.15),
selectedBorderColor: Colors.blueAccent,
selectedColor: Colors.blueAccent,
color: Colors.grey,
constraints: BoxConstraints(
minHeight: 30,
minWidth: (screenWidth < 400 ? 28 : 36),
),
isSelected: [isChartView, !isChartView],
onPressed: (index) {
controller.projectIsChartView.value = index == 0;
},
children: const [
Icon(Icons.bar_chart_rounded, size: 15),
Icon(Icons.table_chart, size: 15),
],
),
],
),
const SizedBox(height: 6),
Row(
children: [
_buildRangeButton("7D", selectedRange),
_buildRangeButton("15D", selectedRange),
_buildRangeButton("30D", selectedRange),
_buildRangeButton("3M", selectedRange),
_buildRangeButton("6M", selectedRange),
],
),
],
);
}
Widget _buildRangeButton(String label, String selectedRange) {
return Padding(
padding: const EdgeInsets.only(right: 4.0),
child: ChoiceChip(
label: Text(label, style: const TextStyle(fontSize: 12)),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 0),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
selected: selectedRange == label,
onSelected: (_) => controller.updateProjectRange(label),
selectedColor: Colors.blueAccent.withOpacity(0.15),
backgroundColor: Colors.grey.shade200,
labelStyle: TextStyle(
color: selectedRange == label ? Colors.blueAccent : Colors.black87,
fontWeight:
selectedRange == label ? FontWeight.w600 : FontWeight.normal,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
side: BorderSide(
color: selectedRange == label
? Colors.blueAccent
: Colors.grey.shade300,
),
),
),
);
}
Widget _buildChart(double height) {
final nonZeroData =
data.where((d) => d.planned != 0 || d.completed != 0).toList();
if (nonZeroData.isEmpty) {
return _buildNoDataContainer(height);
}
return Container(
height: height > 280 ? 280 : height,
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
// Remove background
color: Colors.transparent,
borderRadius: BorderRadius.circular(5),
),
child: SfCartesianChart(
tooltipBehavior: TooltipBehavior(enable: true),
legend: Legend(isVisible: true, position: LegendPosition.bottom),
primaryXAxis: CategoryAxis(
majorGridLines: const MajorGridLines(width: 0),
axisLine: const AxisLine(width: 0),
labelRotation: 0,
),
primaryYAxis: NumericAxis(
labelFormat: '{value}',
axisLine: const AxisLine(width: 0),
majorTickLines: const MajorTickLines(size: 0),
),
series: <CartesianSeries>[
ColumnSeries<ChartTaskData, String>(
name: 'Planned',
dataSource: nonZeroData,
xValueMapper: (d, _) => DateFormat('MMM d').format(d.date),
yValueMapper: (d, _) => d.planned,
color: _getTaskColor('Planned'),
dataLabelSettings: DataLabelSettings(
isVisible: true,
builder: (data, point, series, pointIndex, seriesIndex) {
final value = seriesIndex == 0
? (data as ChartTaskData).planned
: (data as ChartTaskData).completed;
return Text(
_commaFormatter.format(value),
style: const TextStyle(fontSize: 11),
);
},
),
),
ColumnSeries<ChartTaskData, String>(
name: 'Completed',
dataSource: nonZeroData,
xValueMapper: (d, _) => DateFormat('MMM d').format(d.date),
yValueMapper: (d, _) => d.completed,
color: _getTaskColor('Completed'),
dataLabelSettings: DataLabelSettings(
isVisible: true,
builder: (data, point, series, pointIndex, seriesIndex) {
final value = seriesIndex == 0
? (data as ChartTaskData).planned
: (data as ChartTaskData).completed;
return Text(
_commaFormatter.format(value),
style: const TextStyle(fontSize: 11),
);
},
),
),
],
),
);
}
Widget _buildTable(double maxHeight, double screenWidth) {
final containerHeight = maxHeight > 300 ? 300.0 : maxHeight;
final nonZeroData =
data.where((d) => d.planned != 0 || d.completed != 0).toList();
if (nonZeroData.isEmpty) {
return _buildNoDataContainer(containerHeight);
}
return Container(
height: containerHeight,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(5),
color: Colors.transparent,
),
child: Scrollbar(
thumbVisibility: true,
trackVisibility: true,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: screenWidth),
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: DataTable(
columnSpacing: screenWidth < 600 ? 16 : 36,
headingRowHeight: 44,
headingRowColor: MaterialStateProperty.all(
Colors.blueAccent.withOpacity(0.08)),
headingTextStyle: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.black87),
columns: const [
DataColumn(label: Text('Date')),
DataColumn(label: Text('Planned')),
DataColumn(label: Text('Completed')),
],
rows: nonZeroData.map((task) {
return DataRow(
cells: [
DataCell(Text(DateFormat('d MMM').format(task.date))),
DataCell(Text('${task.planned}',
style: TextStyle(color: _getTaskColor('Planned')))),
DataCell(Text('${task.completed}',
style: TextStyle(color: _getTaskColor('Completed')))),
],
);
}).toList(),
),
),
),
),
),
);
}
Widget _buildNoDataContainer(double height) {
return Container(
height: height > 280 ? 280 : height,
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(5),
),
child: const Center(
child: Text(
'No project progress data for the selected range.',
style: TextStyle(fontSize: 14, color: Colors.grey),
textAlign: TextAlign.center,
),
),
);
}
Widget _buildNoDataMessage() {
return SizedBox(
height: 180,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.info_outline, color: Colors.grey.shade400, size: 54),
const SizedBox(height: 10),
MyText.bodyMedium(
'No project progress data available for the selected range.',
textAlign: TextAlign.center,
color: Colors.grey.shade500,
),
],
),
),
);
}
}

View File

@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
/// Returns a formatted color for the expense status.
Color getExpenseStatusColor(String? status, {String? colorCode}) {
if (colorCode != null && colorCode.isNotEmpty) {
try {
return Color(int.parse(colorCode.replaceFirst('#', '0xff')));
} catch (_) {}
}
switch (status) {
case 'Approval Pending':
return Colors.orange;
case 'Process Pending':
return Colors.blue;
case 'Rejected':
return Colors.red;
case 'Paid':
return Colors.green;
default:
return Colors.black;
}
}
/// Formats amount to currency string.
String formatExpenseAmount(double amount) {
return NumberFormat.currency(
locale: 'en_IN', symbol: '', decimalDigits: 2)
.format(amount);
}
/// Label/Value block as reusable widget.
Widget labelValueBlock(String label, String value) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall(label, fontWeight: 600),
MySpacing.height(4),
MyText.bodySmall(value,
fontWeight: 500, softWrap: true, maxLines: null),
],
);
/// Skeleton loader for lists.
Widget buildLoadingSkeleton() => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 5,
itemBuilder: (_, __) => Container(
margin: const EdgeInsets.only(bottom: 16),
height: 80,
decoration: BoxDecoration(
color: Colors.grey[300], borderRadius: BorderRadius.circular(10)),
),
);
/// Expandable description widget.
class ExpandableDescription extends StatefulWidget {
final String description;
const ExpandableDescription({Key? key, required this.description})
: super(key: key);
@override
State<ExpandableDescription> createState() => _ExpandableDescriptionState();
}
class _ExpandableDescriptionState extends State<ExpandableDescription> {
bool isExpanded = false;
@override
Widget build(BuildContext context) {
final isLong = widget.description.length > 100;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall(
widget.description,
maxLines: isExpanded ? null : 2,
overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis,
fontWeight: 500,
),
if (isLong || !isExpanded)
InkWell(
onTap: () => setState(() => isExpanded = !isExpanded),
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: MyText.labelSmall(
isExpanded ? 'Show less' : 'Show more',
fontWeight: 600,
color: Colors.blue,
),
),
),
],
);
}
}

View File

@ -0,0 +1,422 @@
// expense_form_widgets.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/expense/add_expense_controller.dart';
/// 🔹 Common Colors & Styles
final _hintStyle = TextStyle(fontSize: 14, color: Colors.grey[600]);
final _tileDecoration = BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
);
/// ==========================
/// Section Title
/// ==========================
class SectionTitle extends StatelessWidget {
final IconData icon;
final String title;
final bool requiredField;
const SectionTitle({
required this.icon,
required this.title,
this.requiredField = false,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final color = Colors.grey[700];
return Row(
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 8),
RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
children: [
TextSpan(text: title),
if (requiredField)
const TextSpan(
text: ' *',
style: TextStyle(color: Colors.red),
),
],
),
),
],
);
}
}
/// ==========================
/// Custom Text Field
/// ==========================
class CustomTextField extends StatelessWidget {
final TextEditingController controller;
final String hint;
final int maxLines;
final TextInputType keyboardType;
final String? Function(String?)? validator;
const CustomTextField({
required this.controller,
required this.hint,
this.maxLines = 1,
this.keyboardType = TextInputType.text,
this.validator,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
maxLines: maxLines,
keyboardType: keyboardType,
validator: validator,
decoration: InputDecoration(
hintText: hint,
hintStyle: _hintStyle,
filled: true,
fillColor: Colors.grey.shade100,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
),
),
);
}
}
/// ==========================
/// Dropdown Tile
/// ==========================
class DropdownTile extends StatelessWidget {
final String title;
final VoidCallback onTap;
const DropdownTile({
required this.title,
required this.onTap,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: _tileDecoration,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(title,
style: const TextStyle(fontSize: 14, color: Colors.black87),
overflow: TextOverflow.ellipsis),
),
const Icon(Icons.arrow_drop_down),
],
),
),
);
}
}
/// ==========================
/// Tile Container
/// ==========================
class TileContainer extends StatelessWidget {
final Widget child;
const TileContainer({required this.child, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(14),
decoration: _tileDecoration,
child: child);
}
/// ==========================
/// Attachments Section
/// ==========================
class AttachmentsSection extends StatelessWidget {
final RxList<File> attachments;
final RxList<Map<String, dynamic>> existingAttachments;
final ValueChanged<File> onRemoveNew;
final ValueChanged<Map<String, dynamic>>? onRemoveExisting;
final VoidCallback onAdd;
const AttachmentsSection({
required this.attachments,
required this.existingAttachments,
required this.onRemoveNew,
this.onRemoveExisting,
required this.onAdd,
Key? key,
}) : super(key: key);
static const allowedImageExtensions = ['jpg', 'jpeg', 'png'];
bool _isImageFile(File file) {
final ext = file.path.split('.').last.toLowerCase();
return allowedImageExtensions.contains(ext);
}
@override
Widget build(BuildContext context) {
return Obx(() {
final activeExisting =
existingAttachments.where((doc) => doc['isActive'] != false).toList();
final imageFiles = attachments.where(_isImageFile).toList();
final imageExisting = activeExisting
.where((d) =>
(d['contentType']?.toString().startsWith('image/') ?? false))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (activeExisting.isNotEmpty) ...[
const Text("Existing Attachments",
style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: activeExisting.map((doc) {
final isImage =
doc['contentType']?.toString().startsWith('image/') ??
false;
final url = doc['url'];
final fileName = doc['fileName'] ?? 'Unnamed';
return _buildExistingTile(
context,
doc,
isImage,
url,
fileName,
imageExisting,
);
}).toList(),
),
const SizedBox(height: 16),
],
Wrap(
spacing: 8,
runSpacing: 8,
children: [
...attachments.map((file) => GestureDetector(
onTap: () => _onNewTap(context, file, imageFiles),
child: _AttachmentTile(
file: file,
onRemove: () => onRemoveNew(file),
),
)),
_buildActionTile(Icons.attach_file, onAdd),
_buildActionTile(Icons.camera_alt,
() => Get.find<AddExpenseController>().pickFromCamera()),
],
),
],
);
});
}
/// helper for new file tap
void _onNewTap(BuildContext context, File file, List<File> imageFiles) {
if (_isImageFile(file)) {
showDialog(
context: context,
builder: (_) => ImageViewerDialog(
imageSources: imageFiles,
initialIndex: imageFiles.indexOf(file),
),
);
} else {
showAppSnackbar(
title: 'Info',
message: 'Preview for this file type is not supported.',
type: SnackbarType.info,
);
}
}
/// helper for existing file tile
Widget _buildExistingTile(
BuildContext context,
Map<String, dynamic> doc,
bool isImage,
String? url,
String fileName,
List<Map<String, dynamic>> imageExisting,
) {
return Stack(
clipBehavior: Clip.none,
children: [
GestureDetector(
onTap: () async {
if (isImage) {
final sources = imageExisting.map((e) => e['url']).toList();
final idx = imageExisting.indexOf(doc);
showDialog(
context: context,
builder: (_) =>
ImageViewerDialog(imageSources: sources, initialIndex: idx),
);
} else if (url != null && await canLaunchUrlString(url)) {
await launchUrlString(url, mode: LaunchMode.externalApplication);
} else {
showAppSnackbar(
title: 'Error',
message: 'Could not open the document.',
type: SnackbarType.error,
);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: _tileDecoration.copyWith(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(isImage ? Icons.image : Icons.insert_drive_file,
size: 20, color: Colors.grey[600]),
const SizedBox(width: 7),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120),
child: Text(fileName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12)),
),
],
),
),
),
if (onRemoveExisting != null)
Positioned(
top: -6,
right: -6,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.red, size: 18),
onPressed: () => onRemoveExisting?.call(doc),
),
),
],
);
}
Widget _buildActionTile(IconData icon, VoidCallback onTap) => GestureDetector(
onTap: onTap,
child: Container(
width: 50,
height: 50,
decoration: _tileDecoration.copyWith(
border: Border.all(color: Colors.grey.shade400),
),
child: Icon(icon, size: 30, color: Colors.grey),
),
);
}
/// ==========================
/// Attachment Tile
/// ==========================
class _AttachmentTile extends StatelessWidget {
final File file;
final VoidCallback onRemove;
const _AttachmentTile({required this.file, required this.onRemove});
@override
Widget build(BuildContext context) {
final fileName = file.path.split('/').last;
final extension = fileName.split('.').last.toLowerCase();
final isImage =
AttachmentsSection.allowedImageExtensions.contains(extension);
final (icon, color) = _fileIcon(extension);
return Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 80,
height: 80,
decoration: _tileDecoration,
child: isImage
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(file, fit: BoxFit.cover),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 30),
const SizedBox(height: 4),
Text(extension.toUpperCase(),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: color)),
],
),
),
Positioned(
top: -6,
right: -6,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.red, size: 18),
onPressed: onRemove,
),
),
],
);
}
/// map extensions to icons/colors
static (IconData, Color) _fileIcon(String ext) {
switch (ext) {
case 'pdf':
return (Icons.picture_as_pdf, Colors.redAccent);
case 'doc':
case 'docx':
return (Icons.description, Colors.blueAccent);
case 'xls':
case 'xlsx':
return (Icons.table_chart, Colors.green);
case 'txt':
return (Icons.article, Colors.grey);
default:
return (Icons.insert_drive_file, Colors.blueGrey);
}
}
}

View File

@ -0,0 +1,346 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/controller/expense/expense_screen_controller.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/model/expense/expense_list_model.dart';
import 'package:marco/view/expense/expense_detail_screen.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
final ProjectController projectController;
const ExpenseAppBar({required this.projectController, super.key});
@override
Size get preferredSize => const Size.fromHeight(72);
@override
Widget build(BuildContext context) {
return AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge('Expenses', fontWeight: 700),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (_) {
final name = projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
name,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
fontWeight: 600,
),
),
],
);
},
),
],
),
),
],
),
),
);
}
}
class SearchAndFilter extends StatelessWidget {
final TextEditingController controller;
final ValueChanged<String> onChanged;
final VoidCallback onFilterTap;
final ExpenseController expenseController;
const SearchAndFilter({
required this.controller,
required this.onChanged,
required this.onFilterTap,
required this.expenseController,
super.key,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: MySpacing.fromLTRB(12, 10, 12, 0),
child: Row(
children: [
Expanded(
child: SizedBox(
height: 35,
child: TextField(
controller: controller,
onChanged: onChanged,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
prefixIcon:
const Icon(Icons.search, size: 20, color: Colors.grey),
hintText: 'Search expenses...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
),
),
),
MySpacing.width(4),
Obx(() {
return IconButton(
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.tune, color: Colors.black),
if (expenseController.isFilterApplied)
Positioned(
top: -1,
right: -1,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
),
),
),
],
),
onPressed: onFilterTap,
);
}),
],
),
);
}
}
class ToggleButtonsRow extends StatelessWidget {
final bool isHistoryView;
final ValueChanged<bool> onToggle;
const ToggleButtonsRow({
required this.isHistoryView,
required this.onToggle,
super.key,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: MySpacing.fromLTRB(8, 12, 8, 5),
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: const Color(0xFFF0F0F0),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
_ToggleButton(
label: 'Expenses',
icon: Icons.receipt_long,
selected: !isHistoryView,
onTap: () => onToggle(false),
),
_ToggleButton(
label: 'History',
icon: Icons.history,
selected: isHistoryView,
onTap: () => onToggle(true),
),
],
),
),
);
}
}
class _ToggleButton extends StatelessWidget {
final String label;
final IconData icon;
final bool selected;
final VoidCallback onTap;
const _ToggleButton({
required this.label,
required this.icon,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 10),
decoration: BoxDecoration(
color: selected ? Colors.red : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon,
size: 16, color: selected ? Colors.white : Colors.grey),
const SizedBox(width: 6),
MyText.bodyMedium(label,
color: selected ? Colors.white : Colors.grey,
fontWeight: 600),
],
),
),
),
);
}
}
class ExpenseList extends StatelessWidget {
final List<ExpenseModel> expenseList;
final Future<void> Function()? onViewDetail;
const ExpenseList({
required this.expenseList,
this.onViewDetail,
super.key,
});
void _showDeleteConfirmation(BuildContext context, ExpenseModel expense) {
final ExpenseController controller = Get.find<ExpenseController>();
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => ConfirmDialog(
title: "Delete Expense",
message: "Are you sure you want to delete this draft expense?",
confirmText: "Delete",
cancelText: "Cancel",
icon: Icons.delete_forever,
confirmColor: Colors.redAccent,
onConfirm: () async {
await controller.deleteExpense(expense.id);
},
),
);
}
@override
Widget build(BuildContext context) {
if (expenseList.isEmpty && !Get.find<ExpenseController>().isLoading.value) {
return Center(child: MyText.bodyMedium('No expenses found.'));
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
itemCount: expenseList.length,
separatorBuilder: (_, __) =>
Divider(color: Colors.grey.shade300, height: 20),
itemBuilder: (context, index) {
final expense = expenseList[index];
final formattedDate = DateTimeUtils.convertUtcToLocal(
expense.transactionDate.toIso8601String(),
format: 'dd MMM yyyy',
);
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () async {
final result = await Get.to(
() => ExpenseDetailScreen(expenseId: expense.id),
arguments: {'expense': expense},
);
if (result == true && onViewDetail != null) {
await onViewDetail!();
}
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(expense.expensesType.name,
fontWeight: 600),
Row(
children: [
MyText.bodyMedium('${expense.formattedAmount}',
fontWeight: 600),
if (expense.status.name.toLowerCase() == 'draft') ...[
const SizedBox(width: 8),
GestureDetector(
onTap: () =>
_showDeleteConfirmation(context, expense),
child: const Icon(Icons.delete,
color: Colors.red, size: 20),
),
],
],
),
],
),
const SizedBox(height: 6),
Row(
children: [
MyText.bodySmall(formattedDate, fontWeight: 500),
const Spacer(),
MyText.bodySmall(expense.status.name, fontWeight: 500),
],
),
],
),
),
),
);
},
);
}
}

View File

@ -43,7 +43,7 @@ class _ImageViewerDialogState extends State<ImageViewerDialog> {
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 12,
offset: const Offset(0, 4),
),
@ -92,8 +92,7 @@ class _ImageViewerDialogState extends State<ImageViewerDialog> {
},
errorBuilder: (context, error, stackTrace) =>
const Center(
child: Icon(Icons.broken_image,
size: 48, color: Colors.grey),
child: Icon(Icons.broken_image, size: 48, color: Colors.grey),
),
),
);

View File

@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class ConfirmDialog extends StatelessWidget {
final String title;
final String message;
final String confirmText;
final String cancelText;
final IconData icon;
final Color confirmColor;
final Future<void> Function() onConfirm;
final RxBool? isProcessing;
const ConfirmDialog({
super.key,
required this.title,
required this.message,
required this.onConfirm,
this.confirmText = "Delete",
this.cancelText = "Cancel",
this.icon = Icons.delete,
this.confirmColor = Colors.redAccent,
this.isProcessing,
});
@override
Widget build(BuildContext context) {
// Use provided RxBool, or create one internally
final RxBool loading = isProcessing ?? false.obs;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
child: _ContentView(
title: title,
message: message,
icon: icon,
confirmColor: confirmColor,
confirmText: confirmText,
cancelText: cancelText,
loading: loading,
onConfirm: onConfirm,
),
),
);
}
}
class _ContentView extends StatelessWidget {
final String title, message, confirmText, cancelText;
final IconData icon;
final Color confirmColor;
final RxBool loading;
final Future<void> Function() onConfirm;
const _ContentView({
required this.title,
required this.message,
required this.icon,
required this.confirmColor,
required this.confirmText,
required this.cancelText,
required this.loading,
required this.onConfirm,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: confirmColor),
const SizedBox(height: 16),
MyText.titleLarge(
title,
fontWeight: 600,
color: theme.colorScheme.onBackground,
),
const SizedBox(height: 12),
MyText.bodySmall(
message,
textAlign: TextAlign.center,
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: Obx(() => _DialogButton(
text: cancelText,
icon: Icons.close,
color: Colors.grey,
isLoading: false,
onPressed: loading.value
? null // disable while loading
: () => Navigator.pop(context, false),
)),
),
const SizedBox(width: 12),
Expanded(
child: Obx(() => _DialogButton(
text: confirmText,
icon: Icons.delete_forever,
color: confirmColor,
isLoading: loading.value,
onPressed: () async {
try {
loading.value = true;
await onConfirm(); // 🔥 call API
Navigator.pop(context, true); // close on success
} catch (e) {
// Show error, dialog stays open
showAppSnackbar(
title: "Error",
message: "Failed to delete. Try again.",
type: SnackbarType.error,
);
} finally {
loading.value = false;
}
},
)),
),
],
),
],
);
}
}
class _DialogButton extends StatelessWidget {
final String text;
final IconData icon;
final Color color;
final VoidCallback? onPressed;
final bool isLoading;
const _DialogButton({
required this.text,
required this.icon,
required this.color,
required this.onPressed,
this.isLoading = false,
});
@override
Widget build(BuildContext context) {
return ElevatedButton.icon(
onPressed: isLoading ? null : onPressed,
icon: isLoading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Icon(icon, color: Colors.white),
label: MyText.bodyMedium(
isLoading ? "Submitting.." : text,
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: color,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 12),
),
);
}
}

View File

@ -4,36 +4,370 @@ import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/utils/my_shadow.dart';
class SkeletonLoaders {
static Widget buildLoadingSkeleton() {
return SizedBox(
height: 360,
child: Column(
children: List.generate(5, (index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(6, (i) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
width: 48,
height: 16,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
);
}),
),
),
);
}),
),
);
}
static Widget buildLoadingSkeleton() {
return SizedBox(
height: 360,
child: Column(
children: List.generate(5, (index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
// Date Skeleton Loader
static Widget dateSkeletonLoader() {
return Container(
height: 14,
width: 90,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
);
}
// Chart Skeleton Loader
static Widget chartSkeletonLoader() {
return MyCard.bordered(
margin: MySpacing.only(bottom: 12),
paddingAll: 16,
borderRadiusAll: 16,
shadow: MyShadow(
elevation: 1.5,
position: MyShadowPosition.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Chart Title Placeholder
Container(
height: 14,
width: 120,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
),
MySpacing.height(20),
// Chart Bars (variable height for realism)
SizedBox(
height: 180,
child: Row(
children: List.generate(6, (i) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
width: 48,
height: 16,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(6, (index) {
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Container(
height:
(60 + (index * 20)).toDouble(), // fake chart shape
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
),
),
);
}),
),
),
MySpacing.height(16),
// X-Axis Labels
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: List.generate(6, (index) {
return Container(
height: 10,
width: 30,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
);
}),
),
],
),
);
}
// Document List Skeleton Loader
static Widget documentSkeletonLoader() {
return Column(
children: List.generate(5, (index) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Date placeholder
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Container(
height: 12,
width: 80,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
),
),
// Document Card Skeleton
Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Icon Placeholder
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.description,
color: Colors.transparent), // invisible icon
),
const SizedBox(width: 12),
// Text placeholders
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 12,
width: 80,
color: Colors.grey.shade300,
),
MySpacing.height(6),
Container(
height: 14,
width: double.infinity,
color: Colors.grey.shade300,
),
MySpacing.height(6),
Container(
height: 12,
width: 100,
color: Colors.grey.shade300,
),
],
),
),
// Action icon placeholder
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
],
),
),
],
);
}),
),
);
}
);
}
// Document Details Card Skeleton Loader
static Widget documentDetailsSkeletonLoader() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Details Card
Container(
constraints: const BoxConstraints(maxWidth: 460),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 16,
width: 180,
color: Colors.grey.shade300,
),
const SizedBox(height: 8),
Container(
height: 12,
width: 120,
color: Colors.grey.shade300,
),
],
),
),
],
),
const SizedBox(height: 12),
// Tags placeholder
Wrap(
spacing: 6,
runSpacing: 6,
children: List.generate(3, (index) {
return Container(
height: 20,
width: 60,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(12),
),
);
}),
),
const SizedBox(height: 16),
// Info rows placeholders
Column(
children: List.generate(10, (index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Container(
height: 12,
width: 120,
color: Colors.grey.shade300,
),
const SizedBox(width: 12),
Expanded(
child: Container(
height: 12,
color: Colors.grey.shade300,
),
),
],
),
);
}),
),
],
),
),
const SizedBox(height: 20),
// Versions section skeleton
Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(3, (index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 12,
width: 180,
color: Colors.grey.shade300,
),
const SizedBox(height: 6),
Container(
height: 10,
width: 120,
color: Colors.grey.shade300,
),
],
),
),
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
],
),
);
}),
),
),
],
),
);
}
// Employee List - Card Style
static Widget employeeListSkeletonLoader() {
@ -63,25 +397,37 @@ static Widget buildLoadingSkeleton() {
children: [
Row(
children: [
Container(height: 14, width: 100, color: Colors.grey.shade300),
Container(
height: 14,
width: 100,
color: Colors.grey.shade300),
MySpacing.width(8),
Container(height: 12, width: 60, color: Colors.grey.shade300),
Container(
height: 12, width: 60, color: Colors.grey.shade300),
],
),
MySpacing.height(8),
Row(
children: [
Icon(Icons.email, size: 16, color: Colors.grey.shade300),
Icon(Icons.email,
size: 16, color: Colors.grey.shade300),
MySpacing.width(4),
Container(height: 10, width: 140, color: Colors.grey.shade300),
Container(
height: 10,
width: 140,
color: Colors.grey.shade300),
],
),
MySpacing.height(8),
Row(
children: [
Icon(Icons.phone, size: 16, color: Colors.grey.shade300),
Icon(Icons.phone,
size: 16, color: Colors.grey.shade300),
MySpacing.width(4),
Container(height: 10, width: 100, color: Colors.grey.shade300),
Container(
height: 10,
width: 100,
color: Colors.grey.shade300),
],
),
],
@ -122,16 +468,28 @@ static Widget buildLoadingSkeleton() {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 12, width: 100, color: Colors.grey.shade300),
Container(
height: 12,
width: 100,
color: Colors.grey.shade300),
MySpacing.height(8),
Container(height: 10, width: 80, color: Colors.grey.shade300),
Container(
height: 10,
width: 80,
color: Colors.grey.shade300),
MySpacing.height(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Container(height: 28, width: 60, color: Colors.grey.shade300),
Container(
height: 28,
width: 60,
color: Colors.grey.shade300),
MySpacing.width(8),
Container(height: 28, width: 60, color: Colors.grey.shade300),
Container(
height: 28,
width: 60,
color: Colors.grey.shade300),
],
),
],
@ -167,7 +525,8 @@ static Widget buildLoadingSkeleton() {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(height: 14, width: 120, color: Colors.grey.shade300),
Container(
height: 14, width: 120, color: Colors.grey.shade300),
Icon(Icons.add_circle, color: Colors.grey.shade300),
],
),
@ -226,58 +585,198 @@ static Widget buildLoadingSkeleton() {
}),
);
}
static Widget contactSkeletonCard() {
return MyCard.bordered(
margin: MySpacing.only(bottom: 12),
paddingAll: 16,
borderRadiusAll: 16,
shadow: MyShadow(
elevation: 1.5,
position: MyShadowPosition.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
static Widget expenseListSkeletonLoader() {
return ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
itemCount: 6, // Show 6 skeleton items
separatorBuilder: (_, __) =>
Divider(color: Colors.grey.shade300, height: 20),
itemBuilder: (context, index) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 40,
width: 40,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
// Title and Amount
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
height: 14,
width: 120,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
),
Container(
height: 14,
width: 80,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
),
],
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 12,
width: 100,
const SizedBox(height: 6),
// Date and Status
Row(
children: [
Container(
height: 12,
width: 100,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
MySpacing.height(6),
Container(
height: 10,
width: 60,
),
const Spacer(),
Container(
height: 12,
width: 50,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
],
),
),
],
),
],
),
MySpacing.height(16),
Container(height: 10, width: 150, color: Colors.grey.shade300),
MySpacing.height(8),
Container(height: 10, width: 100, color: Colors.grey.shade300),
MySpacing.height(8),
Container(height: 10, width: 120, color: Colors.grey.shade300),
],
),
);
}
);
},
);
}
static Widget employeeSkeletonCard() {
return MyCard.bordered(
margin: MySpacing.only(bottom: 12),
paddingAll: 12,
borderRadiusAll: 12,
shadow: MyShadow(
elevation: 1.5,
position: MyShadowPosition.bottom,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar
Container(
height: 35,
width: 35,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
MySpacing.width(12),
// Name, org, email, phone
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 12, width: 120, color: Colors.grey.shade300),
MySpacing.height(6),
Container(height: 10, width: 80, color: Colors.grey.shade300),
MySpacing.height(8),
// Email placeholder
Row(
children: [
Icon(Icons.email_outlined,
size: 14, color: Colors.grey.shade300),
MySpacing.width(4),
Container(
height: 10, width: 140, color: Colors.grey.shade300),
],
),
MySpacing.height(8),
// Phone placeholder
Row(
children: [
Icon(Icons.phone_outlined,
size: 14, color: Colors.grey.shade300),
MySpacing.width(4),
Container(
height: 10, width: 100, color: Colors.grey.shade300),
MySpacing.width(8),
Container(
height: 16,
width: 16,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
],
),
MySpacing.height(8),
// Tags placeholder
Container(height: 8, width: 80, color: Colors.grey.shade300),
],
),
),
// Arrow
Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey.shade300),
],
),
);
}
static Widget contactSkeletonCard() {
return MyCard.bordered(
margin: MySpacing.only(bottom: 12),
paddingAll: 16,
borderRadiusAll: 16,
shadow: MyShadow(
elevation: 1.5,
position: MyShadowPosition.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
height: 40,
width: 40,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 12,
width: 100,
color: Colors.grey.shade300,
),
MySpacing.height(6),
Container(
height: 10,
width: 60,
color: Colors.grey.shade300,
),
],
),
),
],
),
MySpacing.height(16),
Container(height: 10, width: 150, color: Colors.grey.shade300),
MySpacing.height(8),
Container(height: 10, width: 100, color: Colors.grey.shade300),
MySpacing.height(8),
Container(height: 10, width: 120, color: Colors.grey.shade300),
],
),
);
}
}

View File

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
class MyRefreshIndicator extends StatelessWidget {
final Future<void> Function() onRefresh;
final Widget child;
final Color color;
final Color backgroundColor;
final double strokeWidth;
final double displacement;
const MyRefreshIndicator({
super.key,
required this.onRefresh,
required this.child,
this.color = Colors.white,
this.backgroundColor = Colors.blueAccent,
this.strokeWidth = 3.0,
this.displacement = 40.0,
});
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: onRefresh,
color: color,
backgroundColor: backgroundColor,
strokeWidth: strokeWidth,
displacement: displacement,
child: child,
);
}
}

View File

@ -38,7 +38,7 @@ void showAppSnackbar({
snackPosition: SnackPosition.BOTTOM,
margin: const EdgeInsets.all(16),
borderRadius: 8,
duration: const Duration(seconds: 3),
duration: const Duration(seconds: 5),
icon: Icon(
iconData,
color: Colors.white,

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class TeamBottomSheet {
static void show({
@ -9,46 +11,61 @@ class TeamBottomSheet {
}) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
backgroundColor: Colors.white,
builder: (_) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title and Close Icon
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyLarge("Team Members", fontWeight: 600),
IconButton(
icon: const Icon(Icons.close, size: 20, color: Colors.black54),
onPressed: () => Navigator.pop(context),
),
],
),
const Divider(thickness: 1.2),
// Team Member Rows
...teamMembers.map((member) => _buildTeamMemberRow(member)),
],
),
),
);
}
static Widget _buildTeamMemberRow(dynamic member) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Avatar(firstName: member.firstName, lastName: '', size: 36),
const SizedBox(width: 10),
MyText.bodyMedium(member.firstName, fontWeight: 500),
],
),
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) {
return BaseBottomSheet(
title: 'Team Members',
onCancel: () => Navigator.pop(context),
onSubmit: () {},
showButtons: false,
child: _TeamMemberList(teamMembers: teamMembers),
);
},
);
}
}
class _TeamMemberList extends StatelessWidget {
final List<dynamic> teamMembers;
const _TeamMemberList({required this.teamMembers});
@override
Widget build(BuildContext context) {
if (teamMembers.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: MyText.bodySmall(
"No team members found.",
fontWeight: 600,
color: Colors.grey,
),
),
);
}
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: teamMembers.length,
separatorBuilder: (_, __) => const Divider(thickness: 0.8, height: 12),
itemBuilder: (_, index) {
final member = teamMembers[index];
final String name = member.firstName ?? 'Unnamed';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Avatar(firstName: member.firstName, lastName: '', size: 36),
MySpacing.width(10),
MyText.bodyMedium(name, fontWeight: 500),
],
),
);
},
);
}
}

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/model/directory/contact_bucket_list_model.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class TeamMembersBottomSheet {
static void show(
@ -11,8 +13,9 @@ class TeamMembersBottomSheet {
bool canEdit = false,
VoidCallback? onEdit,
}) {
// Ensure the owner is at the top of the list
final ownerId = bucket.createdBy.id;
// Ensure owner is listed first
members.sort((a, b) {
if (a.id == ownerId) return -1;
if (b.id == ownerId) return 1;
@ -23,201 +26,185 @@ class TeamMembersBottomSheet {
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
isDismissible: true,
enableDrag: true,
builder: (context) {
return SafeArea(
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: DraggableScrollableSheet(
expand: false,
initialChildSize: 0.7,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (context, scrollController) {
return Column(
children: [
const SizedBox(height: 6),
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 10),
MyText.titleMedium(
'Bucket Details',
fontWeight: 700,
),
const SizedBox(height: 12),
// Header with title and edit
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: MyText.titleMedium(
bucket.name,
fontWeight: 700,
),
),
if (canEdit)
IconButton(
onPressed: onEdit,
icon: const Icon(Icons.edit, color: Colors.red),
tooltip: 'Edit Bucket',
),
],
),
),
// Info
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (bucket.description.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: MyText.bodySmall(
bucket.description,
color: Colors.grey[700],
),
),
Row(
children: [
const Icon(Icons.contacts_outlined,
size: 14, color: Colors.grey),
const SizedBox(width: 4),
MyText.labelSmall(
'${bucket.numberOfContacts} contact(s)',
fontWeight: 600,
color: Colors.red,
),
const SizedBox(width: 12),
const Icon(Icons.ios_share_outlined,
size: 14, color: Colors.grey),
const SizedBox(width: 4),
MyText.labelSmall(
'Shared with (${members.length})',
fontWeight: 600,
color: Colors.indigo,
),
],
),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
const Icon(Icons.edit_outlined,
size: 14, color: Colors.grey),
const SizedBox(width: 4),
MyText.labelSmall(
canEdit
? 'Can be edited by you'
: 'You dont have edit access',
fontWeight: 600,
color: canEdit ? Colors.green : Colors.grey,
),
],
),
),
const SizedBox(height: 8),
const Divider(thickness: 1),
const SizedBox(height: 6),
MyText.labelLarge(
'Shared with',
fontWeight: 700,
color: Colors.black,
),
],
),
),
const SizedBox(height: 4),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: members.isEmpty
? Center(
child: MyText.bodySmall(
"No team members found.",
fontWeight: 600,
color: Colors.grey,
),
)
: ListView.separated(
controller: scrollController,
itemCount: members.length,
separatorBuilder: (_, __) =>
const SizedBox(height: 4),
itemBuilder: (context, index) {
final member = members[index];
final firstName = member.firstName ?? '';
final lastName = member.lastName ?? '';
final isOwner =
member.id == bucket.createdBy.id;
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Avatar(
firstName: firstName,
lastName: lastName,
size: 32,
),
title: Row(
children: [
Expanded(
child: MyText.bodyMedium(
'${firstName.isNotEmpty ? firstName : 'Unnamed'} ${lastName.isNotEmpty ? lastName : ''}',
fontWeight: 600,
),
),
if (isOwner)
Container(
margin:
const EdgeInsets.only(left: 6),
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius:
BorderRadius.circular(4),
),
child: MyText.labelSmall(
"Owner",
fontWeight: 600,
color: Colors.red,
),
),
],
),
subtitle: MyText.bodySmall(
member.jobRole ?? '',
color: Colors.grey.shade600,
),
);
},
),
),
),
const SizedBox(height: 8),
],
);
},
),
builder: (_) {
return BaseBottomSheet(
title: 'Bucket Details',
onCancel: () => Navigator.pop(context),
onSubmit: () {}, // Not used, but required
showButtons: false,
child: _TeamContent(
bucket: bucket,
members: members,
canEdit: canEdit,
onEdit: onEdit,
ownerId: ownerId,
),
);
},
);
}
}
class _TeamContent extends StatelessWidget {
final ContactBucket bucket;
final List<dynamic> members;
final bool canEdit;
final VoidCallback? onEdit;
final String ownerId;
const _TeamContent({
required this.bucket,
required this.members,
required this.canEdit,
this.onEdit,
required this.ownerId,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
_buildHeader(),
_buildInfo(),
_buildMembersTitle(),
MySpacing.height(8),
SizedBox(
height: 300,
child: _buildMemberList(),
),
],
);
}
Widget _buildHeader() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
children: [
Expanded(
child: MyText.titleMedium(bucket.name, fontWeight: 700),
),
if (canEdit)
IconButton(
onPressed: onEdit,
icon: const Icon(Icons.edit, color: Colors.red),
tooltip: 'Edit Bucket',
),
],
),
);
}
Widget _buildInfo() {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (bucket.description.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: MyText.bodySmall(
bucket.description,
color: Colors.grey[700],
),
),
Row(
children: [
const Icon(Icons.contacts_outlined, size: 14, color: Colors.grey),
const SizedBox(width: 4),
MyText.labelSmall(
'${bucket.numberOfContacts} contact(s)',
fontWeight: 600,
color: Colors.red,
),
const SizedBox(width: 12),
const Icon(Icons.ios_share_outlined, size: 14, color: Colors.grey),
const SizedBox(width: 4),
MyText.labelSmall(
'Shared with (${members.length})',
fontWeight: 600,
color: Colors.indigo,
),
],
),
MySpacing.height(8),
Row(
children: [
const Icon(Icons.edit_outlined, size: 14, color: Colors.grey),
const SizedBox(width: 4),
MyText.labelSmall(
canEdit ? 'Can be edited by you' : 'You dont have edit access',
fontWeight: 600,
color: canEdit ? Colors.green : Colors.grey,
),
],
),
MySpacing.height(12),
const Divider(thickness: 1),
],
),
);
}
Widget _buildMembersTitle() {
return Align(
alignment: Alignment.centerLeft,
child: MyText.labelLarge('Shared with', fontWeight: 700, color: Colors.black),
);
}
Widget _buildMemberList() {
if (members.isEmpty) {
return Center(
child: MyText.bodySmall(
"No team members found.",
fontWeight: 600,
color: Colors.grey,
),
);
}
return ListView.separated(
itemCount: members.length,
separatorBuilder: (_, __) => const SizedBox(height: 6),
itemBuilder: (context, index) {
final member = members[index];
final firstName = member.firstName ?? '';
final lastName = member.lastName ?? '';
final isOwner = member.id == ownerId;
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Avatar(firstName: firstName, lastName: lastName, size: 32),
title: Row(
children: [
Expanded(
child: MyText.bodyMedium(
'${firstName.isNotEmpty ? firstName : 'Unnamed'} ${lastName.isNotEmpty ? lastName : ''}',
fontWeight: 600,
),
),
if (isOwner)
Container(
margin: const EdgeInsets.only(left: 6),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(4),
),
child: MyText.labelSmall(
"Owner",
fontWeight: 600,
color: Colors.red,
),
),
],
),
subtitle: MyText.bodySmall(
member.jobRole ?? '',
color: Colors.grey.shade600,
),
);
},

View File

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/tenant/all_organization_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/model/all_organization_model.dart';
class AllOrganizationListView extends StatelessWidget {
final AllOrganizationController controller;
/// Optional callback when an organization is tapped
final void Function(AllOrganization)? onTapOrganization;
const AllOrganizationListView({
super.key,
required this.controller,
this.onTapOrganization,
});
Widget _loadingPlaceholder() {
return ListView.separated(
itemCount: 5,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 150,
height: 14,
color: Colors.grey.shade400,
),
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: Colors.grey.shade400,
shape: BoxShape.circle,
),
),
],
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Obx(() {
if (controller.isLoadingOrganizations.value) {
return _loadingPlaceholder();
}
if (controller.organizations.isEmpty) {
return Center(
child: MyText.bodyMedium(
"No organizations found",
color: Colors.grey,
),
);
}
return ListView.separated(
itemCount: controller.organizations.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final org = controller.organizations[index];
return ListTile(
title: Text(org.name),
onTap: () {
if (onTapOrganization != null) {
onTapOrganization!(org);
}
},
);
},
);
});
}
}

View File

@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/tenant/organization_selection_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/model/attendance/organization_per_project_list_model.dart';
class OrganizationSelector extends StatelessWidget {
final OrganizationController controller;
/// Called whenever a new organization is selected (including "All Organizations").
final Future<void> Function(Organization?)? onSelectionChanged;
/// Optional height for the selector. If null, uses default padding-based height.
final double? height;
const OrganizationSelector({
super.key,
required this.controller,
this.onSelectionChanged,
this.height,
});
Widget _popupSelector({
required String currentValue,
required List<String> items,
}) {
return PopupMenuButton<String>(
color: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: (name) async {
Organization? org = name == "All Organizations"
? null
: controller.organizations.firstWhere((e) => e.name == name);
controller.selectOrganization(org);
if (onSelectionChanged != null) {
await onSelectionChanged!(org);
}
},
itemBuilder: (context) => items
.map((e) => PopupMenuItem<String>(value: e, child: MyText(e)))
.toList(),
child: Container(
height: height,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
currentValue,
style: const TextStyle(
color: Colors.black87,
fontSize: 13,
height: 1.2,
),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey, size: 18),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
return Obx(() {
if (controller.isLoadingOrganizations.value) {
return Container(
height: height ?? 40,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 100,
height: 14,
color: Colors.grey.shade400,
),
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: Colors.grey.shade400,
shape: BoxShape.circle,
),
),
],
),
);
} else if (controller.organizations.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: MyText.bodyMedium(
"No organizations found",
fontWeight: 500,
color: Colors.grey,
),
),
);
}
final orgNames = [
"All Organizations",
...controller.organizations.map((e) => e.name)
];
return _popupSelector(
currentValue: controller.currentSelection,
items: orgNames,
);
});
}
}

View File

@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/model/tenant/tenant_services_model.dart';
import 'package:marco/controller/tenant/service_controller.dart';
class ServiceSelector extends StatelessWidget {
final ServiceController controller;
/// Called whenever a new service is selected (including "All Services")
final Future<void> Function(Service?)? onSelectionChanged;
/// Optional height for the selector
final double? height;
const ServiceSelector({
super.key,
required this.controller,
this.onSelectionChanged,
this.height,
});
Widget _popupSelector({
required String currentValue,
required List<String> items,
}) {
return PopupMenuButton<String>(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
onSelected: items.isEmpty
? null
: (name) async {
Service? service = name == "All Services"
? null
: controller.services.firstWhere((e) => e.name == name);
controller.selectService(service);
if (onSelectionChanged != null) {
await onSelectionChanged!(service);
}
},
itemBuilder: (context) {
if (items.isEmpty || items.length == 1 && items[0] == "All Services") {
return [
const PopupMenuItem<String>(
enabled: false,
child: Center(
child: Text(
"No services found",
style: TextStyle(color: Colors.grey),
),
),
),
];
}
return items
.map((e) => PopupMenuItem<String>(value: e, child: MyText(e)))
.toList();
},
child: Container(
height: height,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
currentValue.isEmpty ? "No services found" : currentValue,
style: const TextStyle(
color: Colors.black87,
fontSize: 13,
height: 1.2,
),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey, size: 18),
],
),
),
),
);
}
Widget _skeletonSelector() {
return Container(
height: height ?? 40,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 100,
height: 14,
color: Colors.grey.shade400,
),
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: Colors.grey.shade400,
shape: BoxShape.circle,
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Obx(() {
if (controller.isLoadingServices.value) {
return _skeletonSelector();
}
final serviceNames = controller.services.isEmpty
? <String>[]
: <String>[
"All Services",
...controller.services.map((e) => e.name).toList(),
];
final currentValue =
controller.services.isEmpty ? "" : controller.currentSelection;
return _popupSelector(
currentValue: currentValue,
items: serviceNames,
);
});
}
}

View File

@ -1,44 +1,92 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:marco/helpers/services/app_initializer.dart';
import 'package:marco/view/my_app.dart';
import 'package:provider/provider.dart';
import 'package:marco/helpers/theme/app_notifier.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/view/layouts/offline_screen.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize logging system
await initLogging();
logSafe("App starting...");
// Ensure local storage is ready before enabling remote logging
await LocalStorage.init();
logSafe("💡 Local storage initialized (early init for logging).");
// Now safe to enable remote logging
enableRemoteLogging();
try {
await initializeApp();
logSafe("App initialized successfully.");
runApp(
ChangeNotifierProvider<AppNotifier>(
ChangeNotifierProvider(
create: (_) => AppNotifier(),
child: const MyApp(),
child: const MainWrapper(),
),
);
} catch (e, stacktrace) {
logSafe('App failed to initialize.',
logSafe(
'App failed to initialize.',
level: LogLevel.error,
error: e,
stackTrace: stacktrace,
);
runApp(_buildErrorApp());
}
}
runApp(
const MaterialApp(
home: Scaffold(
body: Center(
child: Text(
"Failed to initialize the app.",
style: TextStyle(color: Colors.red),
),
Widget _buildErrorApp() => const MaterialApp(
home: Scaffold(
body: Center(
child: Text(
"Failed to initialize the app.",
style: TextStyle(color: Colors.red),
),
),
),
);
class MainWrapper extends StatefulWidget {
const MainWrapper({super.key});
@override
State<MainWrapper> createState() => _MainWrapperState();
}
class _MainWrapperState extends State<MainWrapper> {
List<ConnectivityResult> _connectivityStatus = [ConnectivityResult.none];
final Connectivity _connectivity = Connectivity();
@override
void initState() {
super.initState();
_initializeConnectivity();
_connectivity.onConnectivityChanged.listen((results) {
setState(() => _connectivityStatus = results);
});
}
Future<void> _initializeConnectivity() async {
final result = await _connectivity.checkConnectivity();
setState(() => _connectivityStatus = result);
}
@override
Widget build(BuildContext context) {
final bool isOffline =
_connectivityStatus.contains(ConnectivityResult.none);
return isOffline
? const MaterialApp(
debugShowCheckedModeBanner: false, home: OfflineScreen())
: const MyApp();
}
}

View File

@ -0,0 +1,184 @@
class AllOrganizationListResponse {
final bool success;
final String message;
final OrganizationData data;
final dynamic errors;
final int statusCode;
final String timestamp;
AllOrganizationListResponse({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory AllOrganizationListResponse.fromJson(Map<String, dynamic> json) {
return AllOrganizationListResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: json['data'] != null
? OrganizationData.fromJson(json['data'])
: OrganizationData(currentPage: 0, totalPages: 0, totalEntities: 0, data: []),
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: json['timestamp'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp,
};
}
}
class OrganizationData {
final int currentPage;
final int totalPages;
final int totalEntities;
final List<AllOrganization> data;
OrganizationData({
required this.currentPage,
required this.totalPages,
required this.totalEntities,
required this.data,
});
factory OrganizationData.fromJson(Map<String, dynamic> json) {
return OrganizationData(
currentPage: json['currentPage'] ?? 0,
totalPages: json['totalPages'] ?? 0,
totalEntities: json['totalEntities'] ?? 0,
data: (json['data'] as List<dynamic>?)
?.map((e) => AllOrganization.fromJson(e))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() {
return {
'currentPage': currentPage,
'totalPages': totalPages,
'totalEntities': totalEntities,
'data': data.map((e) => e.toJson()).toList(),
};
}
}
class AllOrganization {
final String id;
final String name;
final String email;
final String contactPerson;
final String address;
final String contactNumber;
final int sprid;
final String? logoImage;
final String createdAt;
final User? createdBy;
final User? updatedBy;
final String? updatedAt;
final bool isActive;
AllOrganization({
required this.id,
required this.name,
required this.email,
required this.contactPerson,
required this.address,
required this.contactNumber,
required this.sprid,
this.logoImage,
required this.createdAt,
this.createdBy,
this.updatedBy,
this.updatedAt,
required this.isActive,
});
factory AllOrganization.fromJson(Map<String, dynamic> json) {
return AllOrganization(
id: json['id'] ?? '',
name: json['name'] ?? '',
email: json['email'] ?? '',
contactPerson: json['contactPerson'] ?? '',
address: json['address'] ?? '',
contactNumber: json['contactNumber'] ?? '',
sprid: json['sprid'] ?? 0,
logoImage: json['logoImage'],
createdAt: json['createdAt'] ?? '',
createdBy: json['createdBy'] != null ? User.fromJson(json['createdBy']) : null,
updatedBy: json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null,
updatedAt: json['updatedAt'],
isActive: json['isActive'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'contactPerson': contactPerson,
'address': address,
'contactNumber': contactNumber,
'sprid': sprid,
'logoImage': logoImage,
'createdAt': createdAt,
'createdBy': createdBy?.toJson(),
'updatedBy': updatedBy?.toJson(),
'updatedAt': updatedAt,
'isActive': isActive,
};
}
}
class User {
final String id;
final String firstName;
final String lastName;
final String photo;
final String jobRoleId;
final String jobRoleName;
User({
required this.id,
required this.firstName,
required this.lastName,
required this.photo,
required this.jobRoleId,
required this.jobRoleName,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] ?? '',
firstName: json['firstName'] ?? '',
lastName: json['lastName'] ?? '',
photo: json['photo'] ?? '',
jobRoleId: json['jobRoleId'] ?? '',
jobRoleName: json['jobRoleName'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'firstName': firstName,
'lastName': lastName,
'photo': photo,
'jobRoleId': jobRoleId,
'jobRoleName': jobRoleName,
};
}
}

View File

@ -0,0 +1,115 @@
import 'package:intl/intl.dart';
class Employee {
final String id;
final String firstName;
final String lastName;
final String? photo;
final String jobRoleId;
final String jobRoleName;
Employee({
required this.id,
required this.firstName,
required this.lastName,
this.photo,
required this.jobRoleId,
required this.jobRoleName,
});
factory Employee.fromJson(Map<String, dynamic> json) {
return Employee(
id: json['id'],
firstName: json['firstName'] ?? '',
lastName: json['lastName'] ?? '',
photo: json['photo']?.toString(),
jobRoleId: json['jobRoleId'] ?? '',
jobRoleName: json['jobRoleName'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'firstName': firstName,
'lastName': lastName,
'photo': photo,
'jobRoleId': jobRoleId,
'jobRoleName': jobRoleName,
};
}
}
class AttendanceLogViewModel {
final String id;
final String? comment;
final Employee employee;
final DateTime? activityTime;
final int activity;
final String? photo;
final String? thumbPreSignedUrl;
final String? preSignedUrl;
final String? longitude;
final String? latitude;
final DateTime? updatedOn;
final Employee? updatedByEmployee;
final String? documentId;
AttendanceLogViewModel({
required this.id,
this.comment,
required this.employee,
this.activityTime,
required this.activity,
this.photo,
this.thumbPreSignedUrl,
this.preSignedUrl,
this.longitude,
this.latitude,
this.updatedOn,
this.updatedByEmployee,
this.documentId,
});
factory AttendanceLogViewModel.fromJson(Map<String, dynamic> json) {
return AttendanceLogViewModel(
id: json['id'],
comment: json['comment']?.toString(),
employee: Employee.fromJson(json['employee']),
activityTime: json['activityTime'] != null ? DateTime.tryParse(json['activityTime']) : null,
activity: json['activity'] ?? 0,
photo: json['photo']?.toString(),
thumbPreSignedUrl: json['thumbPreSignedUrl']?.toString(),
preSignedUrl: json['preSignedUrl']?.toString(),
longitude: json['longitude']?.toString(),
latitude: json['latitude']?.toString(),
updatedOn: json['updatedOn'] != null ? DateTime.tryParse(json['updatedOn']) : null,
updatedByEmployee: json['updatedByEmployee'] != null ? Employee.fromJson(json['updatedByEmployee']) : null,
documentId: json['documentId']?.toString(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'comment': comment,
'employee': employee.toJson(),
'activityTime': activityTime?.toIso8601String(),
'activity': activity,
'photo': photo,
'thumbPreSignedUrl': thumbPreSignedUrl,
'preSignedUrl': preSignedUrl,
'longitude': longitude,
'latitude': latitude,
'updatedOn': updatedOn?.toIso8601String(),
'updatedByEmployee': updatedByEmployee?.toJson(),
'documentId': documentId,
};
}
String? get formattedDate =>
activityTime != null ? DateFormat('yyyy-MM-dd').format(activityTime!) : null;
String? get formattedTime =>
activityTime != null ? DateFormat('hh:mm a').format(activityTime!) : null;
}

View File

@ -1,117 +1,27 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/dashboard/attendance_screen_controller.dart';
import 'package:marco/controller/attendance/attendance_screen_controller.dart';
import 'package:marco/helpers/utils/attendance_actions.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class AttendanceActionButton extends StatefulWidget {
final dynamic employee;
final AttendanceController attendanceController;
const AttendanceActionButton({
Key? key,
super.key,
required this.employee,
required this.attendanceController,
}) : super(key: key);
});
@override
State<AttendanceActionButton> createState() => _AttendanceActionButtonState();
}
Future<String?> _showCommentBottomSheet(
BuildContext context, String actionText) async {
final TextEditingController commentController = TextEditingController();
String? errorText;
Get.find<ProjectController>().selectedProject?.id;
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
return StatefulBuilder(
builder: (context, setModalState) {
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 24,
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Add Comment for ${capitalizeFirstLetter(actionText)}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 16),
TextField(
controller: commentController,
maxLines: 4,
decoration: InputDecoration(
hintText: 'Type your comment here...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Colors.grey.shade100,
errorText: errorText,
),
onChanged: (_) {
if (errorText != null) {
setModalState(() => errorText = null);
}
},
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () {
final comment = commentController.text.trim();
if (comment.isEmpty) {
setModalState(() {
errorText = 'Comment cannot be empty.';
});
return;
}
Navigator.of(context).pop(comment);
},
child: const Text('Submit'),
),
),
],
),
],
),
);
},
);
},
);
}
String capitalizeFirstLetter(String text) {
if (text.isEmpty) return text;
return text[0].toUpperCase() + text.substring(1);
}
class _AttendanceActionButtonState extends State<AttendanceActionButton> {
late final String uniqueLogKey;
@ -119,60 +29,59 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
void initState() {
super.initState();
uniqueLogKey = AttendanceButtonHelper.getUniqueKey(
widget.employee.employeeId, widget.employee.id);
widget.employee.employeeId,
widget.employee.id,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!widget.attendanceController.uploadingStates
.containsKey(uniqueLogKey)) {
widget.attendanceController.uploadingStates[uniqueLogKey] = false.obs;
}
widget.attendanceController.uploadingStates.putIfAbsent(
uniqueLogKey,
() => false.obs,
);
});
}
Future<DateTime?> showTimePickerForRegularization({
required BuildContext context,
required DateTime checkInTime,
}) async {
Future<DateTime?> _pickRegularizationTime(DateTime checkInTime) async {
final pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(DateTime.now()),
);
if (pickedTime != null) {
final selectedDateTime = DateTime(
checkInTime.year,
checkInTime.month,
checkInTime.day,
pickedTime.hour,
pickedTime.minute,
if (pickedTime == null) return null;
final selected = DateTime(
checkInTime.year,
checkInTime.month,
checkInTime.day,
pickedTime.hour,
pickedTime.minute,
);
final now = DateTime.now();
if (selected.isBefore(checkInTime)) {
showAppSnackbar(
title: "Invalid Time",
message: "Time must be after check-in.",
type: SnackbarType.warning,
);
final now = DateTime.now();
if (selectedDateTime.isBefore(checkInTime)) {
showAppSnackbar(
title: "Invalid Time",
message: "Time must be after check-in.",
type: SnackbarType.warning,
);
return null;
} else if (selectedDateTime.isAfter(now)) {
showAppSnackbar(
title: "Invalid Time",
message: "Future time is not allowed.",
type: SnackbarType.warning,
);
return null;
}
return selectedDateTime;
return null;
}
return null;
if (selected.isAfter(now)) {
showAppSnackbar(
title: "Invalid Time",
message: "Future time is not allowed.",
type: SnackbarType.warning,
);
return null;
}
return selected;
}
void _handleButtonPressed(BuildContext context) async {
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = true;
Future<void> _handleButtonPressed() async {
final controller = widget.attendanceController;
final projectController = Get.find<ProjectController>();
final selectedProjectId = projectController.selectedProject?.id;
@ -182,53 +91,54 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
message: "Please select a project first",
type: SnackbarType.error,
);
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false;
return;
}
int updatedAction;
controller.uploadingStates[uniqueLogKey]?.value = true;
int action;
String actionText;
bool imageCapture = true;
switch (widget.employee.activity) {
case 0:
updatedAction = 0;
case 4:
action = 0;
actionText = ButtonActions.checkIn;
break;
case 1:
if (widget.employee.checkOut == null &&
AttendanceButtonHelper.isOlderThanDays(
widget.employee.checkIn, 2)) {
updatedAction = 2;
final isOldCheckIn =
AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2);
final isOldCheckOut =
AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2);
if (widget.employee.checkOut == null && isOldCheckIn) {
action = 2;
actionText = ButtonActions.requestRegularize;
imageCapture = false;
} else if (widget.employee.checkOut != null &&
AttendanceButtonHelper.isOlderThanDays(
widget.employee.checkOut, 2)) {
updatedAction = 2;
} else if (widget.employee.checkOut != null && isOldCheckOut) {
action = 2;
actionText = ButtonActions.requestRegularize;
} else {
updatedAction = 1;
action = 1;
actionText = ButtonActions.checkOut;
}
break;
case 2:
updatedAction = 2;
action = 2;
actionText = ButtonActions.requestRegularize;
break;
case 4:
updatedAction = 0;
actionText = ButtonActions.checkIn;
break;
default:
updatedAction = 0;
action = 0;
actionText = "Unknown Action";
break;
}
DateTime? selectedTime;
// New condition: Yesterday Check-In + CheckOut action
final isYesterdayCheckIn = widget.employee.checkIn != null &&
DateUtils.isSameDay(
widget.employee.checkIn,
@ -238,67 +148,44 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
if (isYesterdayCheckIn &&
widget.employee.checkOut == null &&
actionText == ButtonActions.checkOut) {
selectedTime = await showTimePickerForRegularization(
context: context,
checkInTime: widget.employee.checkIn!,
);
selectedTime = await _pickRegularizationTime(widget.employee.checkIn!);
if (selectedTime == null) {
widget.attendanceController.uploadingStates[uniqueLogKey]?.value =
false;
controller.uploadingStates[uniqueLogKey]?.value = false;
return;
}
}
final userComment = await _showCommentBottomSheet(context, actionText);
if (userComment == null || userComment.isEmpty) {
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false;
final comment = await _showCommentBottomSheet(
context,
actionText,
selectedTime: selectedTime,
checkInDate: widget.employee.checkIn,
);
if (comment == null || comment.isEmpty) {
controller.uploadingStates[uniqueLogKey]?.value = false;
return;
}
bool success = false;
String? markTime;
if (actionText == ButtonActions.requestRegularize) {
final regularizeTime = selectedTime ??
await showTimePickerForRegularization(
context: context,
checkInTime: widget.employee.checkIn!,
);
if (regularizeTime != null) {
final formattedSelectedTime =
DateFormat("hh:mm a").format(regularizeTime);
success = await widget.attendanceController.captureAndUploadAttendance(
widget.employee.id,
widget.employee.employeeId,
selectedProjectId,
comment: userComment,
action: updatedAction,
imageCapture: imageCapture,
markTime: formattedSelectedTime,
);
}
selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!);
markTime = selectedTime != null
? DateFormat("hh:mm a").format(selectedTime)
: null;
} else if (selectedTime != null) {
// If selectedTime was picked in the new condition
final formattedSelectedTime = DateFormat("hh:mm a").format(selectedTime);
success = await widget.attendanceController.captureAndUploadAttendance(
widget.employee.id,
widget.employee.employeeId,
selectedProjectId,
comment: userComment,
action: updatedAction,
imageCapture: imageCapture,
markTime: formattedSelectedTime,
);
} else {
success = await widget.attendanceController.captureAndUploadAttendance(
widget.employee.id,
widget.employee.employeeId,
selectedProjectId,
comment: userComment,
action: updatedAction,
imageCapture: imageCapture,
);
markTime = DateFormat("hh:mm a").format(selectedTime);
}
final success = await controller.captureAndUploadAttendance(
widget.employee.id,
widget.employee.employeeId,
selectedProjectId,
comment: comment,
action: action,
imageCapture: imageCapture,
markTime: markTime,
);
showAppSnackbar(
title: success ? '${capitalizeFirstLetter(actionText)} Success' : 'Error',
message: success
@ -307,51 +194,51 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
type: success ? SnackbarType.success : SnackbarType.error,
);
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false;
controller.uploadingStates[uniqueLogKey]?.value = false;
if (success) {
widget.attendanceController.fetchEmployeesByProject(selectedProjectId);
widget.attendanceController.fetchAttendanceLogs(selectedProjectId);
await widget.attendanceController
.fetchRegularizationLogs(selectedProjectId);
await widget.attendanceController.fetchProjectData(selectedProjectId);
widget.attendanceController.update();
await controller.fetchTodaysAttendance(selectedProjectId);
await controller.fetchAttendanceLogs(selectedProjectId);
await controller.fetchRegularizationLogs(selectedProjectId);
await controller.fetchProjectData(selectedProjectId);
controller.update();
}
}
@override
Widget build(BuildContext context) {
return Obx(() {
final controller = widget.attendanceController;
final isUploading =
widget.attendanceController.uploadingStates[uniqueLogKey]?.value ??
false;
controller.uploadingStates[uniqueLogKey]?.value ?? false;
final emp = widget.employee;
final isYesterday = AttendanceButtonHelper.isLogFromYesterday(
widget.employee.checkIn, widget.employee.checkOut);
final isTodayApproved = AttendanceButtonHelper.isTodayApproved(
widget.employee.activity, widget.employee.checkIn);
final isYesterday =
AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut);
final isTodayApproved =
AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn);
final isApprovedButNotToday =
AttendanceButtonHelper.isApprovedButNotToday(
widget.employee.activity, isTodayApproved);
emp.activity, isTodayApproved);
final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled(
isUploading: isUploading,
isYesterday: isYesterday,
activity: widget.employee.activity,
activity: emp.activity,
isApprovedButNotToday: isApprovedButNotToday,
);
final buttonText = AttendanceButtonHelper.getButtonText(
activity: widget.employee.activity,
checkIn: widget.employee.checkIn,
checkOut: widget.employee.checkOut,
activity: emp.activity,
checkIn: emp.checkIn,
checkOut: emp.checkOut,
isTodayApproved: isTodayApproved,
);
final buttonColor = AttendanceButtonHelper.getButtonColor(
isYesterday: isYesterday,
isTodayApproved: isTodayApproved,
activity: widget.employee.activity,
activity: emp.activity,
);
return AttendanceActionButtonUI(
@ -359,8 +246,7 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
isButtonDisabled: isButtonDisabled,
buttonText: buttonText,
buttonColor: buttonColor,
onPressed:
isButtonDisabled ? null : () => _handleButtonPressed(context),
onPressed: isButtonDisabled ? null : _handleButtonPressed,
);
});
}
@ -374,48 +260,47 @@ class AttendanceActionButtonUI extends StatelessWidget {
final VoidCallback? onPressed;
const AttendanceActionButtonUI({
Key? key,
super.key,
required this.isUploading,
required this.isButtonDisabled,
required this.buttonText,
required this.buttonColor,
required this.onPressed,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 30,
child: ElevatedButton(
onPressed: isButtonDisabled ? null : onPressed,
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: buttonColor,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
textStyle: const TextStyle(fontSize: 12),
),
child: isUploading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
? Container(
width: 60,
height: 14,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (buttonText.toLowerCase() == 'approved') ...[
if (buttonText.toLowerCase() == 'approved')
const Icon(Icons.check, size: 16, color: Colors.green),
const SizedBox(width: 4),
] else if (buttonText.toLowerCase() == 'rejected') ...[
if (buttonText.toLowerCase() == 'rejected')
const Icon(Icons.close, size: 16, color: Colors.red),
const SizedBox(width: 4),
] else if (buttonText.toLowerCase() == 'requested') ...[
if (buttonText.toLowerCase() == 'requested')
const Icon(Icons.hourglass_top,
size: 16, color: Colors.orange),
if (['approved', 'rejected', 'requested']
.contains(buttonText.toLowerCase()))
const SizedBox(width: 4),
],
Flexible(
child: Text(
buttonText,
@ -429,3 +314,76 @@ class AttendanceActionButtonUI extends StatelessWidget {
);
}
}
Future<String?> _showCommentBottomSheet(
BuildContext context,
String actionText, {
DateTime? selectedTime,
DateTime? checkInDate,
}) async {
final commentController = TextEditingController();
String? errorText;
// Prepare title
String sheetTitle = "Add Comment for ${capitalizeFirstLetter(actionText)}";
if (selectedTime != null && checkInDate != null) {
sheetTitle =
"${capitalizeFirstLetter(actionText)} for ${DateFormat('dd MMM yyyy').format(checkInDate)} at ${DateFormat('hh:mm a').format(selectedTime)}";
}
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
return StatefulBuilder(
builder: (context, setModalState) {
void submit() {
final comment = commentController.text.trim();
if (comment.isEmpty) {
setModalState(() => errorText = 'Comment cannot be empty.');
return;
}
Navigator.of(context).pop(comment);
}
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom),
child: BaseBottomSheet(
title: sheetTitle, // 👈 now showing full sentence as title
onCancel: () => Navigator.of(context).pop(),
onSubmit: submit,
isSubmitting: false,
submitText: 'Submit',
child: TextField(
controller: commentController,
maxLines: 4,
decoration: InputDecoration(
hintText: 'Type your comment here...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Colors.grey.shade100,
errorText: errorText,
),
onChanged: (_) {
if (errorText != null) {
setModalState(() => errorText = null);
}
},
),
),
);
},
);
},
);
}
String capitalizeFirstLetter(String text) =>
text.isEmpty ? text : text[0].toUpperCase() + text.substring(1);

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/dashboard/attendance_screen_controller.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/attendance/attendance_screen_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
class AttendanceFilterBottomSheet extends StatefulWidget {
final AttendanceController controller;
@ -18,7 +20,7 @@ class AttendanceFilterBottomSheet extends StatefulWidget {
});
@override
_AttendanceFilterBottomSheetState createState() =>
State<AttendanceFilterBottomSheet> createState() =>
_AttendanceFilterBottomSheetState();
}
@ -35,14 +37,79 @@ class _AttendanceFilterBottomSheetState
String getLabelText() {
final startDate = widget.controller.startDateAttendance;
final endDate = widget.controller.endDateAttendance;
if (startDate != null && endDate != null) {
final start = DateFormat('dd/MM/yyyy').format(startDate);
final end = DateFormat('dd/MM/yyyy').format(endDate);
final start = DateTimeUtils.formatDate(startDate, 'dd MMM yyyy');
final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy');
return "$start - $end";
}
return "Date Range";
}
Widget _popupSelector({
required String currentValue,
required List<String> items,
required ValueChanged<String> onSelected,
}) {
return PopupMenuButton<String>(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: onSelected,
itemBuilder: (context) => items
.map((e) => PopupMenuItem<String>(
value: e,
child: MyText(e),
))
.toList(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: MyText(
currentValue,
style: const TextStyle(color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
);
}
Widget _buildOrganizationSelector(BuildContext context) {
final orgNames = [
"All Organizations",
...widget.controller.organizations.map((e) => e.name)
];
return _popupSelector(
currentValue:
widget.controller.selectedOrganization?.name ?? "All Organizations",
items: orgNames,
onSelected: (name) {
if (name == "All Organizations") {
setState(() {
widget.controller.selectedOrganization = null;
});
} else {
final selectedOrg = widget.controller.organizations
.firstWhere((org) => org.name == name);
setState(() {
widget.controller.selectedOrganization = selectedOrg;
});
}
},
);
}
List<Widget> buildMainFilters() {
final hasRegularizationPermission = widget.permissionController
.hasPermission(Permissions.regularizeAttendance);
@ -53,83 +120,128 @@ class _AttendanceFilterBottomSheetState
{'label': 'Regularization Requests', 'value': 'regularizationRequests'},
];
final filteredViewOptions = viewOptions.where((item) {
if (item['value'] == 'regularizationRequests') {
return hasRegularizationPermission;
}
return true;
final filteredOptions = viewOptions.where((item) {
return item['value'] != 'regularizationRequests' ||
hasRegularizationPermission;
}).toList();
List<Widget> widgets = [
final List<Widget> widgets = [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
padding: const EdgeInsets.only(bottom: 4),
child: Align(
alignment: Alignment.centerLeft,
child: MyText.titleSmall(
"View",
fontWeight: 600,
),
child: MyText.titleSmall("View", fontWeight: 600),
),
),
...filteredViewOptions.map((item) {
...filteredOptions.map((item) {
return RadioListTile<String>(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
title: Text(item['label']!),
contentPadding: EdgeInsets.zero,
title: MyText.bodyMedium(
item['label']!,
fontWeight: 500,
),
value: item['value']!,
groupValue: tempSelectedTab,
onChanged: (value) => setState(() => tempSelectedTab = value!),
);
}).toList(),
}),
];
// 🔹 Organization filter
widgets.addAll([
const Divider(),
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 12),
child: Align(
alignment: Alignment.centerLeft,
child: MyText.titleSmall("Choose Organization", fontWeight: 600),
),
),
Obx(() {
if (widget.controller.isLoadingOrganizations.value) {
return Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 100,
height: 14,
color: Colors.grey.shade400,
),
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: Colors.grey.shade400,
shape: BoxShape.circle,
),
),
],
),
);
} else if (widget.controller.organizations.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: MyText.bodyMedium(
"No organizations found",
fontWeight: 500,
color: Colors.grey,
),
),
);
}
return _buildOrganizationSelector(context);
}),
]);
// 🔹 Date Range only for attendanceLogs
if (tempSelectedTab == 'attendanceLogs') {
widgets.addAll([
const Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
padding: const EdgeInsets.only(top: 12, bottom: 4),
child: Align(
alignment: Alignment.centerLeft,
child: MyText.titleSmall(
"Date Range",
fontWeight: 600,
),
child: MyText.titleSmall("Date Range", fontWeight: 600),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => widget.controller.selectDateRangeForAttendance(
InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () async {
await widget.controller.selectDateRangeForAttendance(
context,
widget.controller,
);
setState(() {});
},
child: Ink(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(10),
),
child: Ink(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(10),
),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Icon(Icons.date_range, color: Colors.black87),
const SizedBox(width: 12),
Expanded(
child: Text(
getLabelText(),
style: const TextStyle(
fontSize: 16,
color: Colors.black87,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
const Icon(Icons.date_range, color: Colors.black87),
const SizedBox(width: 12),
Expanded(
child: MyText.bodyMedium(
getLabelText(),
fontWeight: 500,
color: Colors.black87,
),
const Icon(Icons.arrow_drop_down, color: Colors.black87),
],
),
),
const Icon(Icons.arrow_drop_down, color: Colors.black87),
],
),
),
),
@ -141,49 +253,19 @@ class _AttendanceFilterBottomSheetState
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: SingleChildScrollView(
return ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: BaseBottomSheet(
title: "Attendance Filter",
submitText: "Apply",
onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context, {
'selectedTab': tempSelectedTab,
'selectedOrganization': widget.controller.selectedOrganization?.id,
}),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(4),
),
),
),
),
...buildMainFilters(),
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color.fromARGB(255, 95, 132, 255),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Apply Filter'),
onPressed: () {
Navigator.pop(context, {
'selectedTab': tempSelectedTab,
});
},
),
),
),
],
crossAxisAlignment: CrossAxisAlignment.start,
children: buildMainFilters(),
),
),
);

View File

@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/attendance_actions.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
class AttendanceLogViewButton extends StatelessWidget {
class AttendanceLogViewButton extends StatefulWidget {
final dynamic employee;
final dynamic attendanceController; // Use correct types as needed
final dynamic attendanceController;
const AttendanceLogViewButton({
Key? key,
@ -13,6 +14,12 @@ class AttendanceLogViewButton extends StatelessWidget {
required this.attendanceController,
}) : super(key: key);
@override
State<AttendanceLogViewButton> createState() =>
_AttendanceLogViewButtonState();
}
class _AttendanceLogViewButtonState extends State<AttendanceLogViewButton> {
Future<void> _openGoogleMaps(
BuildContext context, double lat, double lon) async {
final url = 'https://www.google.com/maps/search/?api=1&query=$lat,$lon';
@ -49,193 +56,248 @@ class AttendanceLogViewButton extends StatelessWidget {
}
void _showLogsBottomSheet(BuildContext context) async {
await attendanceController.fetchLogsView(employee.id.toString());
await widget.attendanceController
.fetchLogsView(widget.employee.id.toString());
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
backgroundColor: Theme.of(context).cardColor,
builder: (context) => Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.titleMedium(
"Attendance Log",
fontWeight: 700,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 12),
if (attendanceController.attendenceLogsView.isEmpty)
Padding(
backgroundColor: Colors.transparent,
builder: (context) {
Map<int, bool> expandedDescription = {};
return BaseBottomSheet(
title: "Attendance Log",
onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context),
showButtons: false,
child: widget.attendanceController.attendenceLogsView.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: Column(
children: const [
children: [
Icon(Icons.info_outline, size: 40, color: Colors.grey),
SizedBox(height: 8),
Text("No attendance logs available."),
MyText.bodySmall("No attendance logs available."),
],
),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: attendanceController.attendenceLogsView.length,
separatorBuilder: (_, __) => const SizedBox(height: 16),
itemBuilder: (_, index) {
final log = attendanceController.attendenceLogsView[index];
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6,
offset: const Offset(0, 2),
)
],
),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
: StatefulBuilder(
builder: (context, setStateSB) {
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount:
widget.attendanceController.attendenceLogsView.length,
separatorBuilder: (_, __) => const SizedBox(height: 16),
itemBuilder: (_, index) {
final log = widget
.attendanceController.attendenceLogsView[index];
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6,
offset: const Offset(0, 2),
)
],
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_getLogIcon(log),
const SizedBox(width: 10),
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
MyText.bodyLarge(
log.formattedDate ?? '-',
fontWeight: 600,
),
MyText.bodySmall(
"Time: ${log.formattedTime ?? '-'}",
color: Colors.grey[700],
),
],
// Header: Icon + Date + Time
Row(
children: [
_getLogIcon(log),
const SizedBox(width: 12),
MyText.bodyLarge(
(log.formattedDate != null &&
log.formattedDate!.isNotEmpty)
? DateTimeUtils.convertUtcToLocal(
log.formattedDate!,
format: 'd MMM yyyy',
)
: '-',
fontWeight: 600,
),
const SizedBox(width: 12),
MyText.bodySmall(
log.formattedTime != null
? "Time: ${log.formattedTime}"
: "",
color: Colors.grey[700],
),
],
),
const SizedBox(height: 12),
const Divider(height: 1, color: Colors.grey),
// Middle Row: Image + Text (Done by, Description & Location)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image Column
if (log.thumbPreSignedUrl != null)
GestureDetector(
onTap: () {
if (log.preSignedUrl != null) {
_showImageDialog(
context, log.preSignedUrl!);
}
},
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
log.thumbPreSignedUrl!,
height: 60,
width: 60,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const Icon(Icons.broken_image,
size: 40, color: Colors.grey),
),
],
),
),
const SizedBox(height: 12),
Row(
if (log.thumbPreSignedUrl != null)
const SizedBox(width: 12),
// Text Column
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
// Done by
if (log.updatedByEmployee != null)
MyText.bodySmall(
"By: ${log.updatedByEmployee!.firstName} ${log.updatedByEmployee!.lastName}",
color: Colors.grey[700],
),
const SizedBox(height: 8),
// Location
if (log.latitude != null &&
log.longitude != null)
GestureDetector(
onTap: () {
final lat = double.tryParse(log
.latitude
.toString()) ??
0.0;
final lon = double.tryParse(log
.longitude
.toString()) ??
0.0;
if (lat >= -90 &&
lat <= 90 &&
lon >= -180 &&
lon <= 180) {
_openGoogleMaps(
context, lat, lon);
} else {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Invalid location coordinates')),
);
}
},
child: const Padding(
padding:
EdgeInsets.only(right: 8.0),
child: Icon(Icons.location_on,
size: 18, color: Colors.blue),
),
Row(
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: () {
final lat = double.tryParse(
log.latitude
.toString()) ??
0.0;
final lon = double.tryParse(
log.longitude
.toString()) ??
0.0;
if (lat >= -90 &&
lat <= 90 &&
lon >= -180 &&
lon <= 180) {
_openGoogleMaps(
context, lat, lon);
} else {
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: MyText.bodySmall(
"Invalid location coordinates")),
);
}
},
child: Row(
children: [
Icon(Icons.location_on,
size: 16,
color: Colors.blue),
SizedBox(width: 4),
MyText.bodySmall(
"View Location",
color: Colors.blue,
decoration:
TextDecoration.underline,
),
],
),
),
],
),
Expanded(
child: MyText.bodyMedium(
log.comment?.isNotEmpty == true
? log.comment
: "No description provided",
fontWeight: 500,
const SizedBox(height: 8),
// Description with label and more/less using MyText
if (log.comment != null &&
log.comment!.isNotEmpty)
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
MyText.bodySmall(
"Description: ${log.comment!}",
maxLines: expandedDescription[
index] ==
true
? null
: 2,
overflow: expandedDescription[
index] ==
true
? TextOverflow.visible
: TextOverflow.ellipsis,
),
if (log.comment!.length > 100)
GestureDetector(
onTap: () {
setStateSB(() {
expandedDescription[
index] =
!(expandedDescription[
index] ==
true);
});
},
child: MyText.bodySmall(
expandedDescription[
index] ==
true
? "less"
: "more",
color: Colors.blue,
fontWeight: 600,
),
),
],
)
else
MyText.bodySmall(
"Description: No description provided",
fontWeight: 700,
),
),
],
),
],
),
),
const SizedBox(width: 16),
if (log.thumbPreSignedUrl != null)
GestureDetector(
onTap: () {
if (log.preSignedUrl != null) {
_showImageDialog(
context, log.preSignedUrl!);
}
},
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
log.thumbPreSignedUrl!,
height: 60,
width: 60,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) {
return const Icon(Icons.broken_image,
size: 20, color: Colors.grey);
},
),
),
)
else
const Icon(Icons.broken_image,
size: 20, color: Colors.grey),
],
),
],
),
],
),
);
},
);
},
)
],
),
),
),
),
);
},
);
}
@ -246,16 +308,16 @@ class AttendanceLogViewButton extends StatelessWidget {
child: ElevatedButton(
onPressed: () => _showLogsBottomSheet(context),
style: ElevatedButton.styleFrom(
backgroundColor: AttendanceActionColors.colors[ButtonActions.checkIn],
backgroundColor: Colors.indigo,
textStyle: const TextStyle(fontSize: 12),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
child: const FittedBox(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
child: MyText.bodySmall(
"View",
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: Colors.white),
color: Colors.white,
),
),
),
@ -276,7 +338,7 @@ class AttendanceLogViewButton extends StatelessWidget {
final today = DateTime(now.year, now.month, now.day);
final logDay = DateTime(logDate.year, logDate.month, logDate.day);
final yesterday = today.subtract(Duration(days: 1));
final yesterday = today.subtract(const Duration(days: 1));
isTodayOrYesterday = (logDay == today) || (logDay == yesterday);
}

View File

@ -0,0 +1,106 @@
class OrganizationListResponse {
final bool success;
final String message;
final List<Organization> data;
final dynamic errors;
final int statusCode;
final String timestamp;
OrganizationListResponse({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory OrganizationListResponse.fromJson(Map<String, dynamic> json) {
return OrganizationListResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: (json['data'] as List<dynamic>?)
?.map((e) => Organization.fromJson(e))
.toList() ??
[],
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: json['timestamp'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data.map((e) => e.toJson()).toList(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp,
};
}
}
class Organization {
final String id;
final String name;
final String email;
final String contactPerson;
final String address;
final String contactNumber;
final int sprid;
final String createdAt;
final dynamic createdBy;
final dynamic updatedBy;
final dynamic updatedAt;
final bool isActive;
Organization({
required this.id,
required this.name,
required this.email,
required this.contactPerson,
required this.address,
required this.contactNumber,
required this.sprid,
required this.createdAt,
this.createdBy,
this.updatedBy,
this.updatedAt,
required this.isActive,
});
factory Organization.fromJson(Map<String, dynamic> json) {
return Organization(
id: json['id'] ?? '',
name: json['name'] ?? '',
email: json['email'] ?? '',
contactPerson: json['contactPerson'] ?? '',
address: json['address'] ?? '',
contactNumber: json['contactNumber'] ?? '',
sprid: json['sprid'] ?? 0,
createdAt: json['createdAt'] ?? '',
createdBy: json['createdBy'],
updatedBy: json['updatedBy'],
updatedAt: json['updatedAt'],
isActive: json['isActive'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'contactPerson': contactPerson,
'address': address,
'contactNumber': contactNumber,
'sprid': sprid,
'createdAt': createdAt,
'createdBy': createdBy,
'updatedBy': updatedBy,
'updatedAt': updatedAt,
'isActive': isActive,
};
}
}

View File

@ -3,12 +3,12 @@ import 'package:marco/helpers/utils/attendance_actions.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:get/get.dart';
enum ButtonActions { approve, reject }
class RegularizeActionButton extends StatefulWidget {
final dynamic
attendanceController;
final dynamic log;
final dynamic attendanceController;
final dynamic log;
final String uniqueLogKey;
final ButtonActions action;
@ -53,57 +53,60 @@ class _RegularizeActionButtonState extends State<RegularizeActionButton> {
Colors.grey;
}
Future<void> _handlePress() async {
final projectController = Get.find<ProjectController>();
final selectedProjectId = projectController.selectedProject?.id;
Future<void> _handlePress() async {
final projectController = Get.find<ProjectController>();
final selectedProjectId = projectController.selectedProject?.id;
if (selectedProjectId == null) {
showAppSnackbar(
title: 'Warning',
message: 'Please select a project first',
type: SnackbarType.warning,
if (selectedProjectId == null) {
showAppSnackbar(
title: 'Warning',
message: 'Please select a project first',
type: SnackbarType.warning,
);
return;
}
setState(() {
isUploading = true;
});
widget.attendanceController.uploadingStates[widget.uniqueLogKey]?.value =
true;
final success =
await widget.attendanceController.captureAndUploadAttendance(
widget.log.id,
widget.log.employeeId,
selectedProjectId,
comment: _buttonComments[widget.action]!,
action: _buttonActionCodes[widget.action]!,
imageCapture: false,
);
return;
showAppSnackbar(
title: success ? 'Success' : 'Error',
message: success
? '${capitalizeFirstLetter(_buttonTexts[widget.action]!)} marked successfully!'
: 'Failed to mark ${capitalizeFirstLetter(_buttonTexts[widget.action]!)}.',
type: success ? SnackbarType.success : SnackbarType.error,
);
if (success) {
widget.attendanceController.fetchEmployeesByProject(selectedProjectId);
widget.attendanceController.fetchAttendanceLogs(selectedProjectId);
await widget.attendanceController
.fetchRegularizationLogs(selectedProjectId);
await widget.attendanceController.fetchProjectData(selectedProjectId);
}
widget.attendanceController.uploadingStates[widget.uniqueLogKey]?.value =
false;
setState(() {
isUploading = false;
});
}
setState(() {
isUploading = true;
});
widget.attendanceController.uploadingStates[widget.uniqueLogKey]?.value = true;
final success = await widget.attendanceController.captureAndUploadAttendance(
widget.log.id,
widget.log.employeeId,
selectedProjectId,
comment: _buttonComments[widget.action]!,
action: _buttonActionCodes[widget.action]!,
imageCapture: false,
);
showAppSnackbar(
title: success ? 'Success' : 'Error',
message: success
? '${capitalizeFirstLetter(_buttonTexts[widget.action]!)} marked successfully!'
: 'Failed to mark ${capitalizeFirstLetter(_buttonTexts[widget.action]!)}.',
type: success ? SnackbarType.success : SnackbarType.error,
);
if (success) {
widget.attendanceController.fetchEmployeesByProject(selectedProjectId);
widget.attendanceController.fetchAttendanceLogs(selectedProjectId);
await widget.attendanceController.fetchRegularizationLogs(selectedProjectId);
await widget.attendanceController.fetchProjectData(selectedProjectId);
}
widget.attendanceController.uploadingStates[widget.uniqueLogKey]?.value = false;
setState(() {
isUploading = false;
});
}
@override
Widget build(BuildContext context) {
final buttonText = _buttonTexts[widget.action]!;
@ -116,17 +119,19 @@ class _RegularizeActionButtonState extends State<RegularizeActionButton> {
onPressed: isUploading ? null : _handlePress,
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor,
foregroundColor:
Colors.white, // Ensures visibility on all backgrounds
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 6),
minimumSize: const Size(60, 20),
textStyle: const TextStyle(fontSize: 12),
),
child: isUploading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
? Container(
width: 60,
height: 14,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
)
: FittedBox(
fit: BoxFit.scaleDown,

View File

@ -1,58 +0,0 @@
import 'package:intl/intl.dart';
class AttendanceLogViewModel {
final DateTime? activityTime;
final String? imageUrl;
final String? comment;
final String? thumbPreSignedUrl;
final String? preSignedUrl;
final String? longitude;
final String? latitude;
final int? activity;
AttendanceLogViewModel({
this.activityTime,
this.imageUrl,
this.comment,
this.thumbPreSignedUrl,
this.preSignedUrl,
this.longitude,
this.latitude,
required this.activity,
});
factory AttendanceLogViewModel.fromJson(Map<String, dynamic> json) {
return AttendanceLogViewModel(
activityTime: json['activityTime'] != null
? DateTime.tryParse(json['activityTime'])
: null,
imageUrl: json['imageUrl']?.toString(),
comment: json['comment']?.toString(),
thumbPreSignedUrl: json['thumbPreSignedUrl']?.toString(),
preSignedUrl: json['preSignedUrl']?.toString(),
longitude: json['longitude']?.toString(),
latitude: json['latitude']?.toString(),
activity: json['activity'] ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'activityTime': activityTime?.toIso8601String(),
'imageUrl': imageUrl,
'comment': comment,
'thumbPreSignedUrl': thumbPreSignedUrl,
'preSignedUrl': preSignedUrl,
'longitude': longitude,
'latitude': latitude,
'activity': activity,
};
}
String? get formattedDate => activityTime != null
? DateFormat('yyyy-MM-dd').format(activityTime!)
: null;
String? get formattedTime =>
activityTime != null ? DateFormat('hh:mm a').format(activityTime!) : null;
}

View File

@ -1,428 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/task_planing/daily_task_planing_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/project_controller.dart';
class AssignTaskBottomSheet extends StatefulWidget {
final String workLocation;
final String activityName;
final int pendingTask;
final String workItemId;
final DateTime assignmentDate;
final String buildingName;
final String floorName;
final String workAreaName;
const AssignTaskBottomSheet({
super.key,
required this.buildingName,
required this.workLocation,
required this.floorName,
required this.workAreaName,
required this.activityName,
required this.pendingTask,
required this.workItemId,
required this.assignmentDate,
});
@override
State<AssignTaskBottomSheet> createState() => _AssignTaskBottomSheetState();
}
class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
final DailyTaskPlaningController controller = Get.find();
final ProjectController projectController = Get.find();
final TextEditingController targetController = TextEditingController();
final TextEditingController descriptionController = TextEditingController();
String? selectedProjectId;
final ScrollController _employeeListScrollController = ScrollController();
@override
void dispose() {
_employeeListScrollController.dispose();
targetController.dispose();
descriptionController.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
selectedProjectId = projectController.selectedProjectId.value;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (selectedProjectId != null) {
controller.fetchEmployeesByProject(selectedProjectId!);
}
});
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Container(
padding: MediaQuery.of(context).viewInsets.add(MySpacing.all(16)),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.assignment, color: Colors.black54),
SizedBox(width: 8),
MyText.titleMedium("Assign Task",
fontSize: 18, fontWeight: 600),
],
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Get.back(),
),
],
),
Divider(),
_infoRow(Icons.location_on, "Work Location",
"${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"),
Divider(),
_infoRow(Icons.pending_actions, "Pending Task of Activity",
"${widget.pendingTask}"),
Divider(),
GestureDetector(
onTap: () {
final RenderBox overlay = Overlay.of(context)
.context
.findRenderObject() as RenderBox;
final Size screenSize = overlay.size;
showMenu(
context: context,
position: RelativeRect.fromLTRB(
screenSize.width / 2 - 100,
screenSize.height / 2 - 20,
screenSize.width / 2 - 100,
screenSize.height / 2 - 20,
),
items: [
const PopupMenuItem(
value: 'all',
child: Text("All Roles"),
),
...controller.roles.map((role) {
return PopupMenuItem(
value: role['id'].toString(),
child: Text(role['name'] ?? 'Unknown Role'),
);
}),
],
).then((value) {
if (value != null) {
controller.onRoleSelected(value == 'all' ? null : value);
}
});
},
child: Row(
children: [
MyText.titleMedium("Select Team :", fontWeight: 600),
const SizedBox(width: 4),
Icon(Icons.filter_alt,
color: const Color.fromARGB(255, 95, 132, 255)),
],
),
),
MySpacing.height(8),
Container(
constraints: BoxConstraints(maxHeight: 150),
child: _buildEmployeeList(),
),
MySpacing.height(8),
Obx(() {
if (controller.selectedEmployees.isEmpty) return Container();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: controller.selectedEmployees.map((e) {
return Obx(() {
final isSelected =
controller.uploadingStates[e.id]?.value ?? false;
if (!isSelected) return Container();
return Chip(
label: Text(e.name,
style: const TextStyle(color: Colors.white)),
backgroundColor:
const Color.fromARGB(255, 95, 132, 255),
deleteIcon:
const Icon(Icons.close, color: Colors.white),
onDeleted: () {
controller.uploadingStates[e.id]?.value = false;
controller.updateSelectedEmployees();
},
);
});
}).toList(),
),
);
}),
_buildTextField(
icon: Icons.track_changes,
label: "Target for Today :",
controller: targetController,
hintText: "Enter target",
keyboardType: TextInputType.number,
validatorType: "target",
),
MySpacing.height(24),
_buildTextField(
icon: Icons.description,
label: "Description :",
controller: descriptionController,
hintText: "Enter task description",
maxLines: 3,
validatorType: "description",
),
MySpacing.height(24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
OutlinedButton.icon(
onPressed: () => Get.back(),
icon: const Icon(Icons.close, color: Colors.red),
label: MyText.bodyMedium("Cancel", color: Colors.red),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 14),
),
),
ElevatedButton.icon(
onPressed: _onAssignTaskPressed,
icon: const Icon(Icons.check_circle_outline,
color: Colors.white),
label:
MyText.bodyMedium("Assign Task", color: Colors.white),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 28, vertical: 14),
),
),
],
),
],
),
),
),
);
}
Widget _buildEmployeeList() {
return Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
final selectedRoleId = controller.selectedRoleId.value;
final filteredEmployees = selectedRoleId == null
? controller.employees
: controller.employees
.where((e) => e.jobRoleID.toString() == selectedRoleId)
.toList();
if (filteredEmployees.isEmpty) {
return const Text("No employees found for selected role.");
}
return Scrollbar(
controller: _employeeListScrollController,
thumbVisibility: true,
interactive: true,
child: ListView.builder(
controller: _employeeListScrollController,
shrinkWrap: true,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: filteredEmployees.length,
itemBuilder: (context, index) {
final employee = filteredEmployees[index];
final rxBool = controller.uploadingStates[employee.id];
return Obx(() => Padding(
padding: const EdgeInsets.symmetric(vertical: 0),
child: Row(
children: [
Theme(
data: Theme.of(context)
.copyWith(unselectedWidgetColor: Colors.black),
child: Checkbox(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
side: const BorderSide(color: Colors.black),
),
value: rxBool?.value ?? false,
onChanged: (bool? selected) {
if (rxBool != null) {
rxBool.value = selected ?? false;
controller.updateSelectedEmployees();
}
},
fillColor:
WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return const Color.fromARGB(255, 95, 132, 255);
}
return Colors.transparent;
}),
checkColor: Colors.white,
side: const BorderSide(color: Colors.black),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(employee.name,
style: TextStyle(fontSize: 14))),
],
),
));
},
),
);
});
}
Widget _buildTextField({
required IconData icon,
required String label,
required TextEditingController controller,
required String hintText,
TextInputType keyboardType = TextInputType.text,
int maxLines = 1,
required String validatorType,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: Colors.black54),
const SizedBox(width: 6),
MyText.titleMedium(label, fontWeight: 600),
],
),
MySpacing.height(6),
TextFormField(
controller: controller,
keyboardType: keyboardType,
maxLines: maxLines,
decoration: InputDecoration(
hintText: hintText,
border: const OutlineInputBorder(),
),
validator: (value) => this
.controller
.formFieldValidator(value, fieldType: validatorType),
),
],
);
}
Widget _infoRow(IconData icon, String title, String value) {
return Padding(
padding: MySpacing.y(6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: Colors.grey[700]),
const SizedBox(width: 8),
Expanded(
child: RichText(
text: TextSpan(
children: [
WidgetSpan(
child: MyText.titleMedium("$title: ",
fontWeight: 600, color: Colors.black),
),
TextSpan(
text: value,
style: const TextStyle(color: Colors.black),
),
],
),
),
),
],
),
);
}
void _onAssignTaskPressed() {
final selectedTeam = controller.uploadingStates.entries
.where((e) => e.value.value)
.map((e) => e.key)
.toList();
if (selectedTeam.isEmpty) {
showAppSnackbar(
title: "Team Required",
message: "Please select at least one team member",
type: SnackbarType.error,
);
return;
}
final target = int.tryParse(targetController.text.trim());
if (target == null || target <= 0) {
showAppSnackbar(
title: "Invalid Input",
message: "Please enter a valid target number",
type: SnackbarType.error,
);
return;
}
if (target > widget.pendingTask) {
showAppSnackbar(
title: "Target Too High",
message:
"Target cannot be greater than pending task (${widget.pendingTask})",
type: SnackbarType.error,
);
return;
}
final description = descriptionController.text.trim();
if (description.isEmpty) {
showAppSnackbar(
title: "Description Required",
message: "Please enter a description",
type: SnackbarType.error,
);
return;
}
controller.assignDailyTask(
workItemId: widget.workItemId,
plannedTask: target,
description: description,
taskTeam: selectedTeam,
assignmentDate: widget.assignmentDate,
);
}
}

View File

@ -1,875 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/task_planing/report_task_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.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:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/widgets/my_team_model_sheet.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'dart:io';
import 'package:marco/model/dailyTaskPlaning/create_task_botom_sheet.dart';
class CommentTaskBottomSheet extends StatefulWidget {
final Map<String, dynamic> taskData;
final VoidCallback? onCommentSuccess;
final String taskDataId;
final String workAreaId;
final String activityId;
const CommentTaskBottomSheet({
super.key,
required this.taskData,
this.onCommentSuccess,
required this.taskDataId,
required this.workAreaId,
required this.activityId,
});
@override
State<CommentTaskBottomSheet> createState() => _CommentTaskBottomSheetState();
}
class _Member {
final String firstName;
_Member(this.firstName);
}
class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
with UIMixin {
late ReportTaskController controller;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
controller = Get.put(ReportTaskController(),
tag: widget.taskData['taskId'] ?? UniqueKey().toString());
final data = widget.taskData;
controller.basicValidator.getController('assigned_date')?.text =
data['assignedOn'] ?? '';
controller.basicValidator.getController('assigned_by')?.text =
data['assignedBy'] ?? '';
controller.basicValidator.getController('work_area')?.text =
data['location'] ?? '';
controller.basicValidator.getController('activity')?.text =
data['activity'] ?? '';
controller.basicValidator.getController('planned_work')?.text =
data['plannedWork'] ?? '';
controller.basicValidator.getController('completed_work')?.text =
data['completedWork'] ?? '';
controller.basicValidator.getController('team_members')?.text =
(data['teamMembers'] as List<dynamic>).join(', ');
controller.basicValidator.getController('assigned')?.text =
data['assigned'] ?? '';
controller.basicValidator.getController('task_id')?.text =
data['taskId'] ?? '';
controller.basicValidator.getController('comment')?.clear();
controller.selectedImages.clear();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
});
}
String timeAgo(String dateString) {
try {
DateTime date = DateTime.parse(dateString + "Z").toLocal();
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays > 8) {
return DateFormat('dd-MM-yyyy').format(date);
} else if (difference.inDays >= 1) {
return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago';
} else if (difference.inHours >= 1) {
return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago';
} else if (difference.inMinutes >= 1) {
return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago';
} else {
return 'just now';
}
} catch (e) {
print('Error parsing date: $e');
return '';
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: SingleChildScrollView(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
left: 24,
right: 24,
top: 12,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
),
),
GetBuilder<ReportTaskController>(
tag: widget.taskData['taskId'] ?? '',
builder: (controller) {
return Form(
key: controller.basicValidator.formKey,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
MySpacing.height(12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MyText.titleMedium(
"Comment Task",
fontWeight: 600,
fontSize: 18,
),
],
),
const SizedBox(height: 12),
// Second row: Right-aligned "+ Create Task" button
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
InkWell(
onTap: () {
showCreateTaskBottomSheet(
workArea:
widget.taskData['location'] ?? '',
activity:
widget.taskData['activity'] ?? '',
completedWork:
widget.taskData['completedWork'] ??
'',
unit: widget.taskData['unit'] ?? '',
onCategoryChanged: (category) {
debugPrint(
"Category changed to: $category");
},
parentTaskId: widget.taskDataId,
plannedTask: int.tryParse(
widget.taskData['plannedWork'] ??
'0') ??
0,
activityId: widget.activityId,
workAreaId: widget.workAreaId,
onSubmit: () {
Navigator.of(context).pop();
},
);
},
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
child: MyText.bodySmall(
"+ Create Task",
fontWeight: 600,
color: Colors.blueAccent,
),
),
),
],
),
],
),
buildRow(
"Assigned By",
controller.basicValidator
.getController('assigned_by')
?.text
.trim(),
icon: Icons.person_outline,
),
buildRow(
"Work Area",
controller.basicValidator
.getController('work_area')
?.text
.trim(),
icon: Icons.place_outlined,
),
buildRow(
"Activity",
controller.basicValidator
.getController('activity')
?.text
.trim(),
icon: Icons.assignment_outlined,
),
buildRow(
"Planned Work",
controller.basicValidator
.getController('planned_work')
?.text
.trim(),
icon: Icons.schedule_outlined,
),
buildRow(
"Completed Work",
controller.basicValidator
.getController('completed_work')
?.text
.trim(),
icon: Icons.done_all_outlined,
),
buildTeamMembers(),
if ((widget.taskData['reportedPreSignedUrls']
as List<dynamic>?)
?.isNotEmpty ==
true)
buildReportedImagesSection(
imageUrls: List<String>.from(
widget.taskData['reportedPreSignedUrls'] ?? []),
context: context,
),
Row(
children: [
Icon(Icons.comment_outlined,
size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall(
"Comment:",
fontWeight: 600,
),
],
),
MySpacing.height(8),
TextFormField(
validator: controller.basicValidator
.getValidation('comment'),
controller: controller.basicValidator
.getController('comment'),
keyboardType: TextInputType.text,
decoration: InputDecoration(
hintText: "eg: Work done successfully",
hintStyle: MyTextStyle.bodySmall(xMuted: true),
border: outlineInputBorder,
enabledBorder: outlineInputBorder,
focusedBorder: focusedInputBorder,
contentPadding: MySpacing.all(16),
isCollapsed: true,
floatingLabelBehavior: FloatingLabelBehavior.never,
),
),
MySpacing.height(16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.camera_alt_outlined,
size: 18, color: Colors.grey[700]),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall("Attach Photos:",
fontWeight: 600),
MySpacing.height(12),
],
),
),
],
),
Obx(() {
final images = controller.selectedImages;
return buildImagePickerSection(
images: images,
onCameraTap: () =>
controller.pickImages(fromCamera: true),
onUploadTap: () =>
controller.pickImages(fromCamera: false),
onRemoveImage: (index) =>
controller.removeImageAt(index),
onPreviewImage: (index) {
showDialog(
context: context,
builder: (_) => ImageViewerDialog(
imageSources: images,
initialIndex: index,
),
);
},
);
}),
MySpacing.height(24),
buildCommentActionButtons(
onCancel: () => Navigator.of(context).pop(),
onSubmit: () async {
if (controller.basicValidator.validateForm()) {
await controller.commentTask(
projectId: controller.basicValidator
.getController('task_id')
?.text ??
'',
comment: controller.basicValidator
.getController('comment')
?.text ??
'',
images: controller.selectedImages,
);
if (widget.onCommentSuccess != null) {
widget.onCommentSuccess!();
}
}
},
isLoading: controller.isLoading,
),
MySpacing.height(10),
if ((widget.taskData['taskComments'] as List<dynamic>?)
?.isNotEmpty ==
true) ...[
Row(
children: [
MySpacing.width(10),
Icon(Icons.chat_bubble_outline,
size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall(
"Comments",
fontWeight: 600,
),
],
),
MySpacing.height(12),
Builder(
builder: (context) {
final comments = List<Map<String, dynamic>>.from(
widget.taskData['taskComments'] as List,
);
return buildCommentList(comments, context);
},
)
],
],
),
),
);
},
),
],
),
),
);
}
Widget buildReportedImagesSection({
required List<String> imageUrls,
required BuildContext context,
String title = "Reported Images",
}) {
if (imageUrls.isEmpty) return const SizedBox();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 0.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall(
title,
fontWeight: 600,
),
],
),
),
MySpacing.height(8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
height: 70,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: imageUrls.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final url = imageUrls[index];
return GestureDetector(
onTap: () {
showDialog(
context: context,
barrierColor: Colors.black54,
builder: (_) => ImageViewerDialog(
imageSources: imageUrls,
initialIndex: index,
),
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
url,
width: 70,
height: 70,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
width: 70,
height: 70,
color: Colors.grey.shade200,
child:
Icon(Icons.broken_image, color: Colors.grey[600]),
),
),
),
);
},
),
),
),
MySpacing.height(16),
],
);
}
Widget buildTeamMembers() {
final teamMembersText =
controller.basicValidator.getController('team_members')?.text ?? '';
final members = teamMembersText
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
MyText.titleSmall(
"Team Members:",
fontWeight: 600,
),
MySpacing.width(12),
GestureDetector(
onTap: () {
TeamBottomSheet.show(
context: context,
teamMembers: members.map((name) => _Member(name)).toList(),
);
},
child: SizedBox(
height: 32,
width: 100,
child: Stack(
children: [
for (int i = 0; i < members.length.clamp(0, 3); i++)
Positioned(
left: i * 24.0,
child: Tooltip(
message: members[i],
child: Avatar(
firstName: members[i],
lastName: '',
size: 32,
),
),
),
if (members.length > 3)
Positioned(
left: 2 * 24.0,
child: CircleAvatar(
radius: 16,
backgroundColor: Colors.grey.shade300,
child: MyText.bodyMedium(
'+${members.length - 3}',
style: const TextStyle(
fontSize: 12, color: Colors.black87),
),
),
),
],
),
),
),
],
),
);
}
Widget buildCommentActionButtons({
required VoidCallback onCancel,
required Future<void> Function() onSubmit,
required RxBool isLoading,
double? buttonHeight,
}) {
return Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.close, color: Colors.red, size: 18),
label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Obx(() {
return ElevatedButton.icon(
onPressed: isLoading.value ? null : () => onSubmit(),
icon: isLoading.value
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Icon(Icons.check_circle_outline, color: Colors.white, size: 18),
label: isLoading.value
? const SizedBox()
: MyText.bodyMedium("Comment", color: Colors.white, fontWeight: 600),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
);
}),
),
],
);
}
Widget buildRow(String label, String? value, {IconData? icon}) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (icon != null)
Padding(
padding: const EdgeInsets.only(right: 8.0, top: 2),
child: Icon(icon, size: 18, color: Colors.grey[700]),
),
MyText.titleSmall(
"$label:",
fontWeight: 600,
),
MySpacing.width(12),
Expanded(
child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"),
),
],
),
);
}
Widget buildCommentList(
List<Map<String, dynamic>> comments, BuildContext context) {
comments.sort((a, b) {
final aDate = DateTime.tryParse(a['date'] ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0);
final bDate = DateTime.tryParse(b['date'] ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0);
return bDate.compareTo(aDate); // newest first
});
return SizedBox(
height: 300,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: comments.length,
itemBuilder: (context, index) {
final comment = comments[index];
final commentText = comment['text'] ?? '-';
final commentedBy = comment['commentedBy'] ?? 'Unknown';
final relativeTime = timeAgo(comment['date'] ?? '');
final imageUrls = List<String>.from(comment['preSignedUrls'] ?? []);
return Container(
margin: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Avatar(
firstName: commentedBy.split(' ').first,
lastName: commentedBy.split(' ').length > 1
? commentedBy.split(' ').last
: '',
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(
commentedBy,
fontWeight: 700,
color: Colors.black87,
),
MyText.bodySmall(
relativeTime,
fontSize: 12,
color: Colors.black54,
),
],
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: MyText.bodyMedium(
commentText,
fontWeight: 500,
color: Colors.black87,
),
),
],
),
const SizedBox(height: 12),
if (imageUrls.isNotEmpty) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.attach_file_outlined,
size: 18, color: Colors.grey[700]),
MyText.bodyMedium(
'Attachments',
fontWeight: 600,
color: Colors.black87,
),
],
),
const SizedBox(height: 8),
SizedBox(
height: 60,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: imageUrls.length,
itemBuilder: (context, imageIndex) {
final imageUrl = imageUrls[imageIndex];
return GestureDetector(
onTap: () {
showDialog(
context: context,
barrierColor: Colors.black54,
builder: (_) => ImageViewerDialog(
imageSources: imageUrls,
initialIndex: imageIndex,
),
);
},
child: Stack(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.grey[100],
boxShadow: [
BoxShadow(
color: Colors.black26,
blurRadius: 6,
offset: Offset(2, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
Container(
color: Colors.grey[300],
child: Icon(Icons.broken_image,
color: Colors.grey[700]),
),
),
),
),
const Positioned(
right: 4,
bottom: 4,
child: Icon(Icons.zoom_in,
color: Colors.white70, size: 16),
),
],
),
);
},
separatorBuilder: (_, __) =>
const SizedBox(width: 12),
),
),
const SizedBox(height: 12),
],
],
),
),
],
),
);
},
),
);
}
Widget buildImagePickerSection({
required List<File> images,
required VoidCallback onCameraTap,
required VoidCallback onUploadTap,
required void Function(int index) onRemoveImage,
required void Function(int initialIndex) onPreviewImage,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (images.isEmpty)
Container(
height: 70,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300, width: 2),
color: Colors.grey.shade100,
),
child: Center(
child: Icon(Icons.photo_camera_outlined,
size: 48, color: Colors.grey.shade400),
),
)
else
SizedBox(
height: 70,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: images.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final file = images[index];
return Stack(
children: [
GestureDetector(
onTap: () => onPreviewImage(index),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
file,
height: 70,
width: 70,
fit: BoxFit.cover,
),
),
),
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: () => onRemoveImage(index),
child: Container(
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(Icons.close,
size: 20, color: Colors.white),
),
),
),
],
);
},
),
),
MySpacing.height(16),
Row(
children: [
Expanded(
child: MyButton.outlined(
onPressed: onCameraTap,
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.camera_alt,
size: 16, color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Capture', color: Colors.blueAccent),
],
),
),
),
MySpacing.width(12),
Expanded(
child: MyButton.outlined(
onPressed: onUploadTap,
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.upload_file,
size: 16, color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Upload', color: Colors.blueAccent),
],
),
),
),
],
),
],
);
}
}

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