Compare commits

..

113 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
f24bff4fad added firebase code 2025-08-18 11:04:55 +05:30
187 changed files with 19306 additions and 6278 deletions

View File

@ -3,6 +3,7 @@ plugins {
id "kotlin-android" id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin" id "dev.flutter.flutter-gradle-plugin"
id("com.google.gms.google-services")
} }
// Load keystore properties from key.properties file // Load keystore properties from key.properties file
@ -14,7 +15,7 @@ if (keystorePropertiesFile.exists()) {
android { android {
// Define the namespace for your Android application // Define the namespace for your Android application
namespace = "com.marco.aiotstage" namespace = "com.marco.aiot"
// Set the compile SDK version based on Flutter's configuration // Set the compile SDK version based on Flutter's configuration
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
// Set the NDK version based on Flutter's configuration // Set the NDK version based on Flutter's configuration
@ -24,6 +25,8 @@ android {
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8
// Enable core library desugaring for Java 8+ APIs
coreLibraryDesugaringEnabled true
} }
// Configure Kotlin options for JVM target // Configure Kotlin options for JVM target
@ -36,7 +39,7 @@ android {
// Specify your unique Application ID. This identifies your app on Google Play. // Specify your unique Application ID. This identifies your app on Google Play.
applicationId = "com.marco.aiot" applicationId = "com.marco.aiot"
// Set minimum and target SDK versions based on Flutter's configuration // Set minimum and target SDK versions based on Flutter's configuration
minSdk = flutter.minSdkVersion minSdk = 23
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
// Set version code and name based on Flutter's configuration (from pubspec.yaml) // Set version code and name based on Flutter's configuration (from pubspec.yaml)
versionCode = flutter.versionCode versionCode = flutter.versionCode
@ -75,3 +78,9 @@ android {
flutter { flutter {
source = "../.." source = "../.."
} }
// Add required dependencies for desugaring
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
}

View File

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

View File

@ -4,8 +4,8 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.READ_CONTACTS"/> <uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <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 <application
android:label="Marco" android:label="Marco"
@ -38,6 +38,9 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="high_importance_channel"/>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

View File

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

View File

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

View File

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

View File

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

55
build_release.sh Normal file
View File

@ -0,0 +1,55 @@
#!/bin/bash
# ===============================
# Flutter APK Build Script (AAB Disabled)
# ===============================
# Exit immediately if a command exits with a non-zero status
set -e
# Colors for pretty output
GREEN='\033[0;32m'
CYAN='\033[0;36m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# App info
APP_NAME="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

View File

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

View File

@ -9,13 +9,13 @@ import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_image_compressor.dart'; import 'package:marco/helpers/widgets/my_image_compressor.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/project_model.dart';
import 'package:marco/model/employee_model.dart'; import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/model/attendance_log_model.dart'; import 'package:marco/model/attendance/attendance_log_model.dart';
import 'package:marco/model/regularization_log_model.dart'; import 'package:marco/model/regularization_log_model.dart';
import 'package:marco/model/attendance_log_view_model.dart'; import 'package:marco/model/attendance/attendance_log_view_model.dart';
import 'package:marco/model/attendance/organization_per_project_list_model.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
class AttendanceController extends GetxController { class AttendanceController extends GetxController {
@ -26,9 +26,13 @@ class AttendanceController extends GetxController {
List<AttendanceLogModel> attendanceLogs = []; List<AttendanceLogModel> attendanceLogs = [];
List<RegularizationLogModel> regularizationLogs = []; List<RegularizationLogModel> regularizationLogs = [];
List<AttendanceLogViewModel> attendenceLogsView = []; List<AttendanceLogViewModel> attendenceLogsView = [];
// ------------------ Organizations ------------------
List<Organization> organizations = [];
Organization? selectedOrganization;
final isLoadingOrganizations = false.obs;
// States // States
String selectedTab = 'Employee List'; String selectedTab = 'todaysAttendance';
DateTime? startDateAttendance; DateTime? startDateAttendance;
DateTime? endDateAttendance; DateTime? endDateAttendance;
@ -39,16 +43,22 @@ class AttendanceController extends GetxController {
final isLoadingRegularizationLogs = true.obs; final isLoadingRegularizationLogs = true.obs;
final isLoadingLogView = true.obs; final isLoadingLogView = true.obs;
final uploadingStates = <String, RxBool>{}.obs; final uploadingStates = <String, RxBool>{}.obs;
var showPendingOnly = false.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
_initializeDefaults(); _initializeDefaults();
// 🔹 Fetch organizations for the selected project
final projectId = Get.find<ProjectController>().selectedProject?.id;
if (projectId != null) {
fetchOrganizations(projectId);
}
} }
void _initializeDefaults() { void _initializeDefaults() {
_setDefaultDateRange(); _setDefaultDateRange();
fetchProjects();
} }
void _setDefaultDateRange() { void _setDefaultDateRange() {
@ -60,30 +70,58 @@ class AttendanceController extends GetxController {
} }
// ------------------ Project & Employee ------------------ // ------------------ Project & Employee ------------------
/// Called when a notification says attendance has been updated
Future<void> fetchProjects() async { Future<void> refreshDataFromNotification({String? projectId}) async {
isLoadingProjects.value = true; projectId ??= Get.find<ProjectController>().selectedProject?.id;
if (projectId == null) {
final response = await ApiService.getProjects(); logSafe("No project selected for attendance refresh from notification",
if (response != null && response.isNotEmpty) { level: LogLevel.warning);
projects = response.map((e) => ProjectModel.fromJson(e)).toList(); return;
logSafe("Projects fetched: ${projects.length}");
} else {
projects = [];
logSafe("Failed to fetch projects or no projects available.",
level: LogLevel.error);
} }
await fetchProjectData(projectId);
isLoadingProjects.value = false; logSafe(
update(['attendance_dashboard_controller']); "Attendance data refreshed from notification for project $projectId");
} }
Future<void> fetchEmployeesByProject(String? projectId) async { // 🔍 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; if (projectId == null) return;
isLoadingEmployees.value = true; isLoadingEmployees.value = true;
final response = await ApiService.getEmployeesByProject(projectId); final response = await ApiService.getTodaysAttendance(
projectId,
organizationId: selectedOrganization?.id,
);
if (response != null) { if (response != null) {
employees = response.map((e) => EmployeeModel.fromJson(e)).toList(); employees = response.map((e) => EmployeeModel.fromJson(e)).toList();
for (var emp in employees) { for (var emp in employees) {
@ -94,11 +132,24 @@ class AttendanceController extends GetxController {
logSafe("Failed to fetch employees for project $projectId", logSafe("Failed to fetch employees for project $projectId",
level: LogLevel.error); level: LogLevel.error);
} }
isLoadingEmployees.value = false; isLoadingEmployees.value = false;
update(); 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 ------------------ // ------------------ Attendance Capture ------------------
Future<bool> captureAndUploadAttendance( Future<bool> captureAndUploadAttendance(
@ -220,8 +271,12 @@ class AttendanceController extends GetxController {
isLoadingAttendanceLogs.value = true; isLoadingAttendanceLogs.value = true;
final response = await ApiService.getAttendanceLogs(projectId, final response = await ApiService.getAttendanceLogs(
dateFrom: dateFrom, dateTo: dateTo); projectId,
dateFrom: dateFrom,
dateTo: dateTo,
organizationId: selectedOrganization?.id,
);
if (response != null) { if (response != null) {
attendanceLogs = attendanceLogs =
response.map((e) => AttendanceLogModel.fromJson(e)).toList(); response.map((e) => AttendanceLogModel.fromJson(e)).toList();
@ -264,7 +319,10 @@ class AttendanceController extends GetxController {
isLoadingRegularizationLogs.value = true; isLoadingRegularizationLogs.value = true;
final response = await ApiService.getRegularizationLogs(projectId); final response = await ApiService.getRegularizationLogs(
projectId,
organizationId: selectedOrganization?.id,
);
if (response != null) { if (response != null) {
regularizationLogs = regularizationLogs =
response.map((e) => RegularizationLogModel.fromJson(e)).toList(); response.map((e) => RegularizationLogModel.fromJson(e)).toList();
@ -312,14 +370,28 @@ class AttendanceController extends GetxController {
Future<void> fetchProjectData(String? projectId) async { Future<void> fetchProjectData(String? projectId) async {
if (projectId == null) return; if (projectId == null) return;
await Future.wait([ await fetchOrganizations(projectId);
fetchEmployeesByProject(projectId),
fetchAttendanceLogs(projectId,
dateFrom: startDateAttendance, dateTo: endDateAttendance),
fetchRegularizationLogs(projectId),
]);
logSafe("Project data fetched for project ID: $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 ------------------ // ------------------ UI Interaction ------------------

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/widgets/my_validators.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/widgets/my_snackbar.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 { class LoginController extends MyController {
final MyFormValidator basicValidator = MyFormValidator(); final MyFormValidator basicValidator = MyFormValidator();
@ -14,6 +14,7 @@ class LoginController extends MyController {
final RxBool isLoading = false.obs; final RxBool isLoading = false.obs;
final RxBool showPassword = false.obs; final RxBool showPassword = false.obs;
final RxBool isChecked = false.obs; final RxBool isChecked = false.obs;
final RxBool showSplash = false.obs;
@override @override
void onInit() { void onInit() {
@ -40,58 +41,55 @@ class LoginController extends MyController {
); );
} }
void onChangeCheckBox(bool? value) { void onChangeCheckBox(bool? value) => isChecked.value = value ?? false;
isChecked.value = value ?? false;
}
void onChangeShowPassword() { void onChangeShowPassword() => showPassword.toggle();
showPassword.toggle();
}
Future<void> onLogin() async { Future<void> onLogin() async {
if (!basicValidator.validateForm()) return; if (!basicValidator.validateForm()) return;
isLoading.value = true; showSplash.value = true;
try { try {
final loginData = basicValidator.getData(); final loginData = basicValidator.getData();
logSafe("Attempting login for user: ${loginData['username']}", ); logSafe("Attempting login for user: ${loginData['username']}");
final errors = await AuthService.loginUser(loginData); final errors = await AuthService.loginUser(loginData);
if (errors != null) { if (errors != null) {
logSafe("Login failed for user: ${loginData['username']} with errors: $errors", level: LogLevel.warning, );
showAppSnackbar( showAppSnackbar(
title: "Login Failed", title: "Login Failed",
message: "Username or password is incorrect", message: "Username or password is incorrect",
type: SnackbarType.error, type: SnackbarType.error,
); );
basicValidator.addErrors(errors); basicValidator.addErrors(errors);
basicValidator.validateForm(); basicValidator.validateForm();
basicValidator.clearErrors(); basicValidator.clearErrors();
} else { } else {
await _handleRememberMe(); await _handleRememberMe();
logSafe("Login successful for user: ${loginData['username']}", ); enableRemoteLogging();
Get.toNamed('/home'); logSafe("Login successful for user: ${loginData['username']}");
Get.offNamed('/select-tenant');
} }
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Exception during login", level: LogLevel.error, error: e, stackTrace: stacktrace);
showAppSnackbar( showAppSnackbar(
title: "Login Error", title: "Login Error",
message: "An unexpected error occurred", message: "An unexpected error occurred",
type: SnackbarType.error, type: SnackbarType.error,
); );
logSafe("Exception during login",
level: LogLevel.error, error: e, stackTrace: stacktrace);
} finally { } finally {
isLoading.value = false; showSplash.value = false;
} }
} }
Future<void> _handleRememberMe() async { Future<void> _handleRememberMe() async {
if (isChecked.value) { if (isChecked.value) {
await LocalStorage.setToken('username', basicValidator.getController('username')!.text); await LocalStorage.setToken(
await LocalStorage.setToken('password', basicValidator.getController('password')!.text); 'username', basicValidator.getController('username')!.text);
await LocalStorage.setToken(
'password', basicValidator.getController('password')!.text);
await LocalStorage.setBool('remember_me', true); await LocalStorage.setBool('remember_me', true);
} else { } else {
await LocalStorage.removeToken('username'); await LocalStorage.removeToken('username');
@ -114,11 +112,7 @@ class LoginController extends MyController {
} }
} }
void goToForgotPassword() { void goToForgotPassword() => Get.toNamed('/auth/forgot_password');
Get.toNamed('/auth/forgot_password');
}
void gotoRegister() { void gotoRegister() => Get.offAndToNamed('/auth/register_account');
Get.offAndToNamed('/auth/register_account');
}
} }

View File

@ -4,8 +4,10 @@ import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/storage/local_storage.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_form_validator.dart';
import 'package:marco/helpers/widgets/my_snackbar.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/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 { class MPINController extends GetxController {
final MyFormValidator basicValidator = MyFormValidator(); final MyFormValidator basicValidator = MyFormValidator();
@ -42,7 +44,8 @@ class MPINController extends GetxController {
/// Handle digit entry and focus movement /// Handle digit entry and focus movement
void onDigitChanged(String value, int index, {bool isRetype = false}) { void onDigitChanged(String value, int index, {bool isRetype = false}) {
logSafe("onDigitChanged -> index: $index, value: $value, isRetype: $isRetype"); logSafe(
"onDigitChanged -> index: $index, value: $value, isRetype: $isRetype");
final nodes = isRetype ? retypeFocusNodes : focusNodes; final nodes = isRetype ? retypeFocusNodes : focusNodes;
if (value.isNotEmpty && index < 3) { if (value.isNotEmpty && index < 3) {
nodes[index + 1].requestFocus(); nodes[index + 1].requestFocus();
@ -136,16 +139,17 @@ class MPINController extends GetxController {
} }
/// Navigate to dashboard /// Navigate to dashboard
void _navigateToDashboard({String? message}) { /// Navigate to tenant selection after MPIN verification
void _navigateToTenantSelection({String? message}) {
if (message != null) { if (message != null) {
logSafe("Navigating to Dashboard with message: $message"); logSafe("Navigating to Tenant Selection with message: $message");
showAppSnackbar( showAppSnackbar(
title: "Success", title: "Success",
message: message, message: message,
type: SnackbarType.success, type: SnackbarType.success,
); );
} }
Get.offAll(() => const DashboardScreen()); Get.offAllNamed('/select-tenant');
} }
/// Clear the primary MPIN fields /// Clear the primary MPIN fields
@ -212,7 +216,8 @@ class MPINController extends GetxController {
if (response == null) { if (response == null) {
return true; return true;
} else { } else {
logSafe("MPIN generation returned error: $response", level: LogLevel.warning); logSafe("MPIN generation returned error: $response",
level: LogLevel.warning);
showAppSnackbar( showAppSnackbar(
title: "MPIN Operation Failed", title: "MPIN Operation Failed",
message: "Please check your inputs.", message: "Please check your inputs.",
@ -236,15 +241,12 @@ class MPINController extends GetxController {
logSafe("verifyMPIN triggered"); logSafe("verifyMPIN triggered");
final enteredMPIN = digitControllers.map((c) => c.text).join(); final enteredMPIN = digitControllers.map((c) => c.text).join();
logSafe("Entered MPIN: $enteredMPIN");
if (enteredMPIN.length < 4) { if (enteredMPIN.length < 4) {
_showError("Please enter all 4 digits."); _showError("Please enter all 4 digits.");
return; return;
} }
final mpinToken = await LocalStorage.getMpinToken(); final mpinToken = await LocalStorage.getMpinToken();
if (mpinToken == null || mpinToken.isEmpty) { if (mpinToken == null || mpinToken.isEmpty) {
_showError("Missing MPIN token. Please log in again."); _showError("Missing MPIN token. Please log in again.");
return; return;
@ -253,9 +255,12 @@ class MPINController extends GetxController {
try { try {
isLoading.value = true; isLoading.value = true;
final fcmToken = await FirebaseNotificationService().getFcmToken();
final response = await AuthService.verifyMpin( final response = await AuthService.verifyMpin(
mpin: enteredMPIN, mpin: enteredMPIN,
mpinToken: mpinToken, mpinToken: mpinToken,
fcmToken: fcmToken ?? '',
); );
isLoading.value = false; isLoading.value = false;
@ -264,15 +269,29 @@ class MPINController extends GetxController {
logSafe("MPIN verified successfully"); logSafe("MPIN verified successfully");
await LocalStorage.setBool('mpin_verified', true); await LocalStorage.setBool('mpin_verified', true);
// 🔹 Ensure controllers are injected and loaded
final token = await LocalStorage.getJwtToken();
if (token != null && token.isNotEmpty) {
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
await Get.find<PermissionController>().loadData(token);
}
if (!Get.isRegistered<ProjectController>()) {
Get.put(ProjectController(), permanent: true);
await Get.find<ProjectController>().fetchProjects();
}
}
showAppSnackbar( showAppSnackbar(
title: "Success", title: "Success",
message: "MPIN Verified Successfully", message: "MPIN Verified Successfully",
type: SnackbarType.success, type: SnackbarType.success,
); );
_navigateToDashboard(); _navigateToTenantSelection();
} else { } else {
final errorMessage = response["error"] ?? "Invalid MPIN"; final errorMessage = response["error"] ?? "Invalid MPIN";
logSafe("MPIN verification failed: $errorMessage", level: LogLevel.warning); logSafe("MPIN verification failed: $errorMessage",
level: LogLevel.warning);
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: errorMessage, message: errorMessage,
@ -284,11 +303,7 @@ class MPINController extends GetxController {
} catch (e) { } catch (e) {
isLoading.value = false; isLoading.value = false;
logSafe("Exception in verifyMPIN", level: LogLevel.error, error: e); logSafe("Exception in verifyMPIN", level: LogLevel.error, error: e);
showAppSnackbar( _showError("Something went wrong. Please try again.");
title: "Error",
message: "Something went wrong. Please try again.",
type: SnackbarType.error,
);
} }
} }

View File

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

View File

@ -49,8 +49,8 @@ class ResetPasswordController extends MyController {
basicValidator.clearErrors(); basicValidator.clearErrors();
} }
logSafe("[ResetPasswordController] Navigating to /home"); logSafe("[ResetPasswordController] Navigating to /dashboard");
Get.toNamed('/home'); Get.toNamed('/dashboard');
update(); update();
} else { } else {
logSafe("[ResetPasswordController] Form validation failed", level: LogLevel.warning); logSafe("[ResetPasswordController] Form validation failed", level: LogLevel.warning);

View File

@ -1,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/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:marco/model/dashboard/project_progress_model.dart';
class DashboardController extends GetxController { class DashboardController extends GetxController {
// Observables // =========================
final RxList<Map<String, dynamic>> roleWiseData = <Map<String, dynamic>>[].obs; // Attendance overview
final RxBool isLoading = false.obs; // =========================
final RxString selectedRange = '15D'.obs; final RxList<Map<String, dynamic>> roleWiseData =
final RxBool isChartView = true.obs; <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 @override
void onInit() { void onInit() {
@ -20,88 +57,207 @@ class DashboardController extends GetxController {
logSafe( logSafe(
'DashboardController initialized with project ID: ${projectController.selectedProjectId.value}', 'DashboardController initialized with project ID: ${projectController.selectedProjectId.value}',
level: LogLevel.info, level: LogLevel.info,
); );
if (projectController.selectedProjectId.value.isNotEmpty) { fetchAllDashboardData();
fetchRoleWiseAttendance();
}
// React to project change // React to project change
ever<String>(projectController.selectedProjectId, (id) { ever<String>(projectController.selectedProjectId, (id) {
if (id.isNotEmpty) { fetchAllDashboardData();
logSafe('Project changed to $id, fetching attendance', level: LogLevel.info, );
fetchRoleWiseAttendance();
}
}); });
// React to range change // React to range changes
ever(selectedRange, (_) { ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
fetchRoleWiseAttendance(); ever(projectSelectedRange, (_) => fetchProjectProgress());
});
} }
int get rangeDays => _getDaysFromRange(selectedRange.value); // =========================
// Helper Methods
// =========================
int _getDaysFromRange(String range) { int _getDaysFromRange(String range) {
switch (range) { switch (range) {
case '7D':
return 7;
case '15D': case '15D':
return 15; return 15;
case '30D': case '30D':
return 30; return 30;
case '7D': case '3M':
return 90;
case '6M':
return 180;
default: default:
return 7; return 7;
} }
} }
void updateRange(String range) { int getAttendanceDays() => _getDaysFromRange(attendanceSelectedRange.value);
selectedRange.value = range; int getProjectDays() => _getDaysFromRange(projectSelectedRange.value);
logSafe('Selected range updated to $range', level: LogLevel.debug);
void updateAttendanceRange(String range) {
attendanceSelectedRange.value = range;
logSafe('Attendance range updated to $range', level: LogLevel.debug);
} }
void toggleChartView(bool isChart) { void updateProjectRange(String range) {
isChartView.value = isChart; projectSelectedRange.value = range;
logSafe('Chart view toggled to: $isChart', level: LogLevel.debug); 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 { Future<void> refreshDashboard() async {
logSafe('Manual dashboard refresh triggered.', level: LogLevel.debug); 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; final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) { 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; return;
} }
try { await Future.wait([
isLoading.value = true; 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 = final List<dynamic>? response =
await ApiService.getDashboardAttendanceOverview(projectId, rangeDays); await ApiService.getDashboardAttendanceOverview(
projectId, getAttendanceDays());
if (response != null) { if (response != null) {
roleWiseData.value = roleWiseData.value =
response.map((e) => Map<String, dynamic>.from(e)).toList(); response.map((e) => Map<String, dynamic>.from(e)).toList();
logSafe('Attendance overview fetched successfully.', level: LogLevel.info); logSafe('Attendance overview fetched successfully.',
level: LogLevel.info);
} else { } else {
roleWiseData.clear(); 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) { } catch (e, st) {
roleWiseData.clear(); roleWiseData.clear();
logSafe( logSafe('Error fetching attendance overview',
'Error fetching attendance overview', level: LogLevel.error, error: e, stackTrace: st);
level: LogLevel.error,
error: e,
stackTrace: st,
);
} finally { } 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

@ -10,7 +10,7 @@ class AddContactController extends GetxController {
final RxList<String> tags = <String>[].obs; final RxList<String> tags = <String>[].obs;
final RxString selectedCategory = ''.obs; final RxString selectedCategory = ''.obs;
final RxString selectedBucket = ''.obs; final RxList<String> selectedBuckets = <String>[].obs;
final RxString selectedProject = ''.obs; final RxString selectedProject = ''.obs;
final RxList<String> enteredTags = <String>[].obs; final RxList<String> enteredTags = <String>[].obs;
@ -50,7 +50,7 @@ class AddContactController extends GetxController {
void resetForm() { void resetForm() {
selectedCategory.value = ''; selectedCategory.value = '';
selectedProject.value = ''; selectedProject.value = '';
selectedBucket.value = ''; selectedBuckets.clear();
enteredTags.clear(); enteredTags.clear();
filteredSuggestions.clear(); filteredSuggestions.clear();
filteredOrgSuggestions.clear(); filteredOrgSuggestions.clear();
@ -94,12 +94,27 @@ class AddContactController extends GetxController {
required List<Map<String, String>> phones, required List<Map<String, String>> phones,
required String address, required String address,
required String description, required String description,
String? designation,
}) async { }) async {
if (isSubmitting.value) return; if (isSubmitting.value) return;
isSubmitting.value = true; isSubmitting.value = true;
final categoryId = categoriesMap[selectedCategory.value]; final categoryId = categoriesMap[selectedCategory.value];
final bucketId = bucketsMap[selectedBucket.value]; final bucketIds = selectedBuckets
.map((name) => bucketsMap[name])
.whereType<String>()
.toList();
if (bucketIds.isEmpty) {
showAppSnackbar(
title: "Missing Buckets",
message: "Please select at least one bucket.",
type: SnackbarType.warning,
);
isSubmitting.value = false;
return;
}
final projectIds = selectedProjects final projectIds = selectedProjects
.map((name) => projectsMap[name]) .map((name) => projectsMap[name])
.whereType<String>() .whereType<String>()
@ -125,10 +140,10 @@ class AddContactController extends GetxController {
return; return;
} }
if (selectedBucket.value.trim().isEmpty || bucketId == null) { if (selectedBuckets.isEmpty) {
showAppSnackbar( showAppSnackbar(
title: "Missing Bucket", title: "Missing Bucket",
message: "Please select a bucket.", message: "Please select at least one bucket.",
type: SnackbarType.warning, type: SnackbarType.warning,
); );
isSubmitting.value = false; isSubmitting.value = false;
@ -150,12 +165,14 @@ class AddContactController extends GetxController {
if (selectedCategory.value.isNotEmpty && categoryId != null) if (selectedCategory.value.isNotEmpty && categoryId != null)
"contactCategoryId": categoryId, "contactCategoryId": categoryId,
if (projectIds.isNotEmpty) "projectIds": projectIds, if (projectIds.isNotEmpty) "projectIds": projectIds,
"bucketIds": [bucketId], "bucketIds": bucketIds,
if (enteredTags.isNotEmpty) "tags": tagObjects, if (enteredTags.isNotEmpty) "tags": tagObjects,
if (emails.isNotEmpty) "contactEmails": emails, if (emails.isNotEmpty) "contactEmails": emails,
if (phones.isNotEmpty) "contactPhones": phones, if (phones.isNotEmpty) "contactPhones": phones,
if (address.trim().isNotEmpty) "address": address.trim(), if (address.trim().isNotEmpty) "address": address.trim(),
if (description.trim().isNotEmpty) "description": description.trim(), if (description.trim().isNotEmpty) "description": description.trim(),
if (designation != null && designation.trim().isNotEmpty)
"designation": designation.trim(),
}; };
logSafe("${id != null ? 'Updating' : 'Creating'} contact"); logSafe("${id != null ? 'Updating' : 'Creating'} contact");

View File

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

View File

@ -1,7 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.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/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/directory/directory_controller.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) { void addNote(NoteModel note) {
notesList.insert(0, note); notesList.insert(0, note);
logSafe("Note added to list"); logSafe("Note added to list");

View File

@ -0,0 +1,82 @@
import 'package:get/get.dart';
import 'package: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

@ -1,12 +1,13 @@
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/my_controller.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/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:marco/helpers/widgets/my_snackbar.dart';
import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:marco/helpers/services/app_logger.dart';
enum Gender { enum Gender {
male, male,
@ -17,122 +18,188 @@ enum Gender {
} }
class AddEmployeeController extends MyController { class AddEmployeeController extends MyController {
List<PlatformFile> files = []; Map<String, dynamic>? editingEmployeeData;
// State
final MyFormValidator basicValidator = MyFormValidator(); final MyFormValidator basicValidator = MyFormValidator();
final List<PlatformFile> files = [];
final List<String> categories = [];
Gender? selectedGender; Gender? selectedGender;
List<Map<String, dynamic>> roles = []; List<Map<String, dynamic>> roles = [];
String? selectedRoleId; String? selectedRoleId;
String selectedCountryCode = "+91"; String selectedCountryCode = '+91';
bool showOnline = true; bool showOnline = true;
final List<String> categories = []; DateTime? joiningDate;
String? selectedOrganizationId;
RxString selectedOrganizationName = RxString('');
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
logSafe("Initializing AddEmployeeController..."); logSafe('Initializing AddEmployeeController...');
_initializeFields(); _initializeFields();
fetchRoles(); fetchRoles();
if (editingEmployeeData != null) {
prefillFields();
}
} }
void _initializeFields() { void _initializeFields() {
basicValidator.addField( basicValidator.addField(
'first_name', 'first_name',
label: "First Name", label: 'First Name',
required: true, required: true,
controller: TextEditingController(), controller: TextEditingController(),
); );
basicValidator.addField( basicValidator.addField(
'phone_number', 'phone_number',
label: "Phone Number", label: 'Phone Number',
required: true, required: true,
controller: TextEditingController(), controller: TextEditingController(),
); );
basicValidator.addField( basicValidator.addField(
'last_name', 'last_name',
label: "Last Name", label: 'Last Name',
required: true, required: true,
controller: TextEditingController(), controller: TextEditingController(),
); );
logSafe("Fields initialized for first_name, phone_number, last_name."); // 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) { void onGenderSelected(Gender? gender) {
selectedGender = gender; selectedGender = gender;
logSafe("Gender selected: ${gender?.name}"); logSafe('Gender selected: ${gender?.name}');
update(); update();
} }
Future<void> fetchRoles() async { Future<void> fetchRoles() async {
logSafe("Fetching roles..."); logSafe('Fetching roles...');
try { try {
final result = await ApiService.getRoles(); final result = await ApiService.getRoles();
if (result != null) { if (result != null) {
roles = List<Map<String, dynamic>>.from(result); roles = List<Map<String, dynamic>>.from(result);
logSafe("Roles fetched successfully."); logSafe('Roles fetched successfully.');
update(); update();
} else { } else {
logSafe("Failed to fetch roles: null result", level: LogLevel.error); logSafe('Failed to fetch roles: null result', level: LogLevel.error);
} }
} catch (e, st) { } catch (e, st) {
logSafe("Error fetching roles", logSafe('Error fetching roles', level: LogLevel.error, error: e, stackTrace: st);
level: LogLevel.error, error: e, stackTrace: st);
} }
} }
void onRoleSelected(String? roleId) { void onRoleSelected(String? roleId) {
selectedRoleId = roleId; selectedRoleId = roleId;
logSafe("Role selected: $roleId"); logSafe('Role selected: $roleId');
update(); update();
} }
Future<Map<String, dynamic>?> createEmployees() async { // Create or update employee
logSafe("Starting employee creation..."); 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) { if (selectedGender == null || selectedRoleId == null) {
logSafe("Missing gender or role.", level: LogLevel.warning);
showAppSnackbar( showAppSnackbar(
title: "Missing Fields", title: 'Missing Fields',
message: "Please select both Gender and Role.", message: 'Please select both Gender and Role.',
type: SnackbarType.warning, type: SnackbarType.warning,
); );
return null; return null;
} }
final firstName = basicValidator.getController("first_name")?.text.trim(); final firstName = basicValidator.getController('first_name')?.text.trim();
final lastName = basicValidator.getController("last_name")?.text.trim(); final lastName = basicValidator.getController('last_name')?.text.trim();
final phoneNumber = final phoneNumber = basicValidator.getController('phone_number')?.text.trim();
basicValidator.getController("phone_number")?.text.trim();
try { try {
// sanitize orgId before sending
final String? orgId = (selectedOrganizationId != null &&
selectedOrganizationId!.trim().isNotEmpty)
? selectedOrganizationId
: null;
final response = await ApiService.createEmployee( final response = await ApiService.createEmployee(
id: editingEmployeeData?['id'],
firstName: firstName!, firstName: firstName!,
lastName: lastName!, lastName: lastName!,
phoneNumber: phoneNumber!, phoneNumber: phoneNumber!,
gender: selectedGender!.name, gender: selectedGender!.name,
jobRoleId: selectedRoleId!, jobRoleId: selectedRoleId!,
joiningDate: joiningDate?.toIso8601String() ?? '',
organizationId: orgId,
email: email,
hasApplicationAccess: hasApplicationAccess,
); );
logSafe("Response: $response"); logSafe('Response: $response');
if (response != null && response['success'] == true) { if (response != null && response['success'] == true) {
logSafe("Employee created successfully.");
showAppSnackbar( showAppSnackbar(
title: "Success", title: 'Success',
message: "Employee created successfully!", message: editingEmployeeData != null
? 'Employee updated successfully!'
: 'Employee created successfully!',
type: SnackbarType.success, type: SnackbarType.success,
); );
return response; return response;
} else { } else {
logSafe("Failed to create employee (response false)", logSafe('Failed operation', level: LogLevel.error);
level: LogLevel.error);
} }
} catch (e, st) { } catch (e, st) {
logSafe("Error creating employee", logSafe('Error creating/updating employee',
level: LogLevel.error, error: e, stackTrace: st); level: LogLevel.error, error: e, stackTrace: st);
} }
showAppSnackbar( showAppSnackbar(
title: "Error", title: 'Error',
message: "Failed to create employee.", message: 'Failed to save employee.',
type: SnackbarType.error, type: SnackbarType.error,
); );
return null; return null;
@ -148,9 +215,8 @@ class AddEmployeeController extends MyController {
} }
showAppSnackbar( showAppSnackbar(
title: "Permission Required", title: 'Permission Required',
message: message: 'Please allow Contacts permission from settings to pick a contact.',
"Please allow Contacts permission from settings to pick a contact.",
type: SnackbarType.warning, type: SnackbarType.warning,
); );
return false; return false;
@ -168,8 +234,8 @@ class AddEmployeeController extends MyController {
await FlutterContacts.getContact(picked.id, withProperties: true); await FlutterContacts.getContact(picked.id, withProperties: true);
if (contact == null) { if (contact == null) {
showAppSnackbar( showAppSnackbar(
title: "Error", title: 'Error',
message: "Failed to load contact details.", message: 'Failed to load contact details.',
type: SnackbarType.error, type: SnackbarType.error,
); );
return; return;
@ -177,8 +243,8 @@ class AddEmployeeController extends MyController {
if (contact.phones.isEmpty) { if (contact.phones.isEmpty) {
showAppSnackbar( showAppSnackbar(
title: "No Phone Number", title: 'No Phone Number',
message: "Selected contact has no phone number.", message: 'Selected contact has no phone number.',
type: SnackbarType.warning, type: SnackbarType.warning,
); );
return; return;
@ -192,8 +258,8 @@ class AddEmployeeController extends MyController {
if (indiaPhones.isEmpty) { if (indiaPhones.isEmpty) {
showAppSnackbar( showAppSnackbar(
title: "No Indian Number", title: 'No Indian Number',
message: "Selected contact has no Indian (+91) phone number.", message: 'Selected contact has no Indian (+91) phone number.',
type: SnackbarType.warning, type: SnackbarType.warning,
); );
return; return;
@ -206,19 +272,20 @@ class AddEmployeeController extends MyController {
selectedPhone = await showDialog<String>( selectedPhone = await showDialog<String>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: Text("Choose an Indian number"), title: const Text('Choose an Indian number'),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: indiaPhones children: indiaPhones
.map((p) => ListTile( .map(
title: Text(p.number), (p) => ListTile(
onTap: () => Navigator.of(ctx).pop(p.number), title: Text(p.number),
)) onTap: () => Navigator.of(ctx).pop(p.number),
),
)
.toList(), .toList(),
), ),
), ),
); );
if (selectedPhone == null) return; if (selectedPhone == null) return;
} }
@ -231,11 +298,11 @@ class AddEmployeeController extends MyController {
phoneWithoutCountryCode; phoneWithoutCountryCode;
update(); update();
} catch (e, st) { } catch (e, st) {
logSafe("Error fetching contacts", logSafe('Error fetching contacts',
level: LogLevel.error, error: e, stackTrace: st); level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar( showAppSnackbar(
title: "Error", title: 'Error',
message: "Failed to fetch contacts.", message: 'Failed to fetch contacts.',
type: SnackbarType.error, type: SnackbarType.error,
); );
} }

View File

@ -1,9 +1,9 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.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/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/model/employees/employee_details_model.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
@ -24,7 +24,7 @@ class EmployeesScreenController extends GetxController {
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
isLoading.value = true; isLoading.value = true;
fetchAllProjects().then((_) { fetchAllProjects().then((_) {
final projectId = Get.find<ProjectController>().selectedProject?.id; final projectId = Get.find<ProjectController>().selectedProject?.id;
if (projectId != null) { if (projectId != null) {
@ -66,21 +66,26 @@ class EmployeesScreenController extends GetxController {
update(['employee_screen_controller']); update(['employee_screen_controller']);
} }
Future<void> fetchAllEmployees() async { Future<void> fetchAllEmployees({String? organizationId}) async {
isLoading.value = true; isLoading.value = true;
update(['employee_screen_controller']); update(['employee_screen_controller']);
await _handleApiCall( await _handleApiCall(
ApiService.getAllEmployees, () => ApiService.getAllEmployees(
organizationId: organizationId), // pass orgId to API
onSuccess: (data) { onSuccess: (data) {
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json))); employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
logSafe("All Employees fetched: ${employees.length} employees loaded.", logSafe(
level: LogLevel.info); "All Employees fetched: ${employees.length} employees loaded.",
level: LogLevel.info,
);
}, },
onEmpty: () { onEmpty: () {
employees.clear(); employees.clear();
logSafe("No Employee data found or API call failed.", logSafe(
level: LogLevel.warning); "No Employee data found or API call failed",
level: LogLevel.warning,
);
}, },
); );
@ -88,43 +93,22 @@ class EmployeesScreenController extends GetxController {
update(['employee_screen_controller']); update(['employee_screen_controller']);
} }
Future<void> fetchEmployeesByProject(String? projectId) async { Future<void> fetchEmployeesByProject(String projectId,
if (projectId == null || projectId.isEmpty) { {String? organizationId}) async {
logSafe("Project ID is required but was null or empty.", if (projectId.isEmpty) return;
level: LogLevel.error);
return;
}
isLoading.value = true; isLoading.value = true;
await _handleApiCall( await _handleApiCall(
() => ApiService.getAllEmployeesByProject(projectId), () => ApiService.getAllEmployeesByProject(projectId,
organizationId: organizationId),
onSuccess: (data) { onSuccess: (data) {
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json))); employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
for (var emp in employees) { for (var emp in employees) {
uploadingStates[emp.id] = false.obs; 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; isLoading.value = false;

View File

@ -7,28 +7,40 @@ import 'package:get/get.dart';
import 'package:geocoding/geocoding.dart'; import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.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/controller/expense/expense_screen_controller.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/employee_model.dart'; import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/payment_types_model.dart';
import 'package:mime/mime.dart';
class AddExpenseController extends GetxController { class AddExpenseController extends GetxController {
// --- Text Controllers --- // --- Text Controllers ---
final amountController = TextEditingController(); final controllers = <TextEditingController>[
final descriptionController = TextEditingController(); TextEditingController(), // amount
final supplierController = TextEditingController(); TextEditingController(), // description
final transactionIdController = TextEditingController(); TextEditingController(), // supplier
final gstController = TextEditingController(); TextEditingController(), // transactionId
final locationController = TextEditingController(); TextEditingController(), // gst
final transactionDateController = TextEditingController(); TextEditingController(), // location
final noOfPersonsController = TextEditingController(); TextEditingController(), // transactionDate
TextEditingController(), // noOfPersons
TextEditingController(), // employeeSearch
];
final employeeSearchController = TextEditingController(); 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 --- // --- Reactive State ---
final isLoading = false.obs; final isLoading = false.obs;
@ -57,30 +69,20 @@ class AddExpenseController extends GetxController {
String? editingExpenseId; String? editingExpenseId;
final expenseController = Get.find<ExpenseController>(); final expenseController = Get.find<ExpenseController>();
final ImagePicker _picker = ImagePicker();
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
fetchMasterData(); loadMasterData();
fetchGlobalProjects(); employeeSearchController.addListener(
employeeSearchController.addListener(() { () => searchEmployees(employeeSearchController.text),
searchEmployees(employeeSearchController.text); );
});
} }
@override @override
void onClose() { void onClose() {
for (var c in [ for (var c in controllers) {
amountController,
descriptionController,
supplierController,
transactionIdController,
gstController,
locationController,
transactionDateController,
noOfPersonsController,
employeeSearchController,
]) {
c.dispose(); c.dispose();
} }
super.onClose(); super.onClose();
@ -91,11 +93,19 @@ class AddExpenseController extends GetxController {
if (query.trim().isEmpty) return employeeSearchResults.clear(); if (query.trim().isEmpty) return employeeSearchResults.clear();
isSearchingEmployees.value = true; isSearchingEmployees.value = true;
try { try {
final data = final data = await ApiService.searchEmployeesBasic(
await ApiService.searchEmployeesBasic(searchString: query.trim()); searchString: query.trim(),
employeeSearchResults.assignAll(
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
); );
if (data is List) {
employeeSearchResults.assignAll(
data
.map((e) => EmployeeModel.fromJson(e as Map<String, dynamic>))
.toList(),
);
} else {
employeeSearchResults.clear();
}
} catch (e) { } catch (e) {
logSafe("Error searching employees: $e", level: LogLevel.error); logSafe("Error searching employees: $e", level: LogLevel.error);
employeeSearchResults.clear(); employeeSearchResults.clear();
@ -104,64 +114,77 @@ class AddExpenseController extends GetxController {
} }
} }
// --- Form Population: Edit Mode --- // --- Form Population (Edit) ---
Future<void> populateFieldsForEdit(Map<String, dynamic> data) async { Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
isEditMode.value = true; isEditMode.value = true;
editingExpenseId = '${data['id']}'; editingExpenseId = '${data['id']}';
selectedProject.value = data['projectName'] ?? ''; selectedProject.value = data['projectName'] ?? '';
amountController.text = data['amount']?.toString() ?? ''; amountController.text = '${data['amount'] ?? ''}';
supplierController.text = data['supplerName'] ?? ''; supplierController.text = data['supplerName'] ?? '';
descriptionController.text = data['description'] ?? ''; descriptionController.text = data['description'] ?? '';
transactionIdController.text = data['transactionId'] ?? ''; transactionIdController.text = data['transactionId'] ?? '';
locationController.text = data['location'] ?? ''; locationController.text = data['location'] ?? '';
noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString(); noOfPersonsController.text = '${data['noOfPersons'] ?? 0}';
// Transaction Date _setTransactionDate(data['transactionDate']);
if (data['transactionDate'] != null) { _setDropdowns(data);
try { await _setPaidBy(data);
final parsed = DateTime.parse(data['transactionDate']); _setAttachments(data['attachments']);
selectedTransactionDate.value = parsed;
transactionDateController.text =
DateFormat('dd-MM-yyyy').format(parsed);
} catch (_) {
selectedTransactionDate.value = null;
transactionDateController.clear();
}
}
// Dropdown
selectedExpenseType.value =
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
selectedPaymentMode.value =
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
// Paid By
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());
}
// Attachments
existingAttachments.clear();
if (data['attachments'] is List) {
existingAttachments.addAll(
List<Map<String, dynamic>>.from(data['attachments'])
.map((e) => {...e, 'isActive': true}),
);
}
_logPrefilledData(); _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() { void _logPrefilledData() {
logSafe('--- Prefilled Expense Data ---', level: LogLevel.info); final info = [
[
'ID: $editingExpenseId', 'ID: $editingExpenseId',
'Project: ${selectedProject.value}', 'Project: ${selectedProject.value}',
'Amount: ${amountController.text}', 'Amount: ${amountController.text}',
@ -176,20 +199,34 @@ class AddExpenseController extends GetxController {
'Paid By: ${selectedPaidBy.value?.name}', 'Paid By: ${selectedPaidBy.value?.name}',
'Attachments: ${attachments.length}', 'Attachments: ${attachments.length}',
'Existing Attachments: ${existingAttachments.length}', 'Existing Attachments: ${existingAttachments.length}',
].forEach((str) => logSafe(str, level: LogLevel.info)); ];
for (var line in info) {
logSafe(line, level: LogLevel.info);
}
} }
// --- Pickers --- // --- Pickers ---
Future<void> pickTransactionDate(BuildContext context) async { Future<void> pickTransactionDate(BuildContext context) async {
final picked = await showDatePicker( final pickedDate = await showDatePicker(
context: context, context: context,
initialDate: selectedTransactionDate.value ?? DateTime.now(), initialDate: selectedTransactionDate.value ?? DateTime.now(),
firstDate: DateTime(DateTime.now().year - 5), firstDate: DateTime(DateTime.now().year - 5),
lastDate: DateTime.now(), lastDate: DateTime.now(),
); );
if (picked != null) {
selectedTransactionDate.value = picked; if (pickedDate != null) {
transactionDateController.text = DateFormat('dd-MM-yyyy').format(picked); 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);
} }
} }
@ -201,8 +238,9 @@ class AddExpenseController extends GetxController {
allowMultiple: true, allowMultiple: true,
); );
if (result != null) { if (result != null) {
attachments attachments.addAll(
.addAll(result.paths.whereType<String>().map((path) => File(path))); result.paths.whereType<String>().map(File.new),
);
} }
} catch (e) { } catch (e) {
_errorSnackbar("Attachment error: $e"); _errorSnackbar("Attachment error: $e");
@ -211,12 +249,20 @@ class AddExpenseController extends GetxController {
void removeAttachment(File file) => attachments.remove(file); 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 --- // --- Location ---
Future<void> fetchCurrentLocation() async { Future<void> fetchCurrentLocation() async {
isFetchingLocation.value = true; isFetchingLocation.value = true;
try { try {
final permission = await _ensureLocationPermission(); if (!await _ensureLocationPermission()) return;
if (!permission) return;
final position = await Geolocator.getCurrentPosition(); final position = await Geolocator.getCurrentPosition();
final placemarks = final placemarks =
@ -228,7 +274,7 @@ class AddExpenseController extends GetxController {
placemarks.first.street, placemarks.first.street,
placemarks.first.locality, placemarks.first.locality,
placemarks.first.administrativeArea, placemarks.first.administrativeArea,
placemarks.first.country placemarks.first.country,
].where((e) => e?.isNotEmpty == true).join(", ") ].where((e) => e?.isNotEmpty == true).join(", ")
: "${position.latitude}, ${position.longitude}"; : "${position.latitude}, ${position.longitude}";
} catch (e) { } catch (e) {
@ -258,19 +304,23 @@ class AddExpenseController extends GetxController {
// --- Data Fetching --- // --- Data Fetching ---
Future<void> loadMasterData() async => Future<void> loadMasterData() async =>
await Future.wait([fetchMasterData(), fetchGlobalProjects()]); Future.wait([fetchMasterData(), fetchGlobalProjects()]);
Future<void> fetchMasterData() async { Future<void> fetchMasterData() async {
try { try {
final types = await ApiService.getMasterExpenseTypes(); final types = await ApiService.getMasterExpenseTypes();
if (types is List) if (types is List) {
expenseTypes.value = expenseTypes.value = types
types.map((e) => ExpenseTypeModel.fromJson(e)).toList(); .map((e) => ExpenseTypeModel.fromJson(e as Map<String, dynamic>))
.toList();
}
final modes = await ApiService.getMasterPaymentModes(); final modes = await ApiService.getMasterPaymentModes();
if (modes is List) if (modes is List) {
paymentModes.value = paymentModes.value = modes
modes.map((e) => PaymentModeModel.fromJson(e)).toList(); .map((e) => PaymentModeModel.fromJson(e as Map<String, dynamic>))
.toList();
}
} catch (_) { } catch (_) {
_errorSnackbar("Failed to fetch master data"); _errorSnackbar("Failed to fetch master data");
} }
@ -282,8 +332,8 @@ class AddExpenseController extends GetxController {
if (response != null) { if (response != null) {
final names = <String>[]; final names = <String>[];
for (var item in response) { for (var item in response) {
final name = item['name']?.toString().trim(), final name = item['name']?.toString().trim();
id = item['id']?.toString().trim(); final id = item['id']?.toString().trim();
if (name != null && id != null) { if (name != null && id != null) {
projectsMap[name] = id; projectsMap[name] = id;
names.add(name); names.add(name);
@ -308,24 +358,7 @@ class AddExpenseController extends GetxController {
} }
final payload = await _buildExpensePayload(); final payload = await _buildExpensePayload();
final success = await _submitToApi(payload);
final success = isEditMode.value && editingExpenseId != null
? await ApiService.editExpenseApi(
expenseId: editingExpenseId!, payload: payload)
: await 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'],
);
if (success) { if (success) {
await expenseController.fetchExpenses(); await expenseController.fetchExpenses();
@ -346,42 +379,71 @@ class AddExpenseController extends GetxController {
} }
} }
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 { Future<Map<String, dynamic>> _buildExpensePayload() async {
final now = DateTime.now(); final now = DateTime.now();
final existingAttachmentPayloads = existingAttachments
.map((e) => {
"documentId": e['documentId'],
"fileName": e['fileName'],
"contentType": e['contentType'],
"fileSize": 0,
"description": "",
"url": e['url'],
"isActive": e['isActive'] ?? true,
"base64Data": e['isActive'] == false ? null : e['base64Data'],
})
.toList();
final newAttachmentPayloads = final existingPayload = isEditMode.value
await Future.wait(attachments.map((file) async { ? existingAttachments
final bytes = await file.readAsBytes(); .map((e) => {
return { "documentId": e['documentId'],
"fileName": file.path.split('/').last, "fileName": e['fileName'],
"base64Data": base64Encode(bytes), "contentType": e['contentType'],
"contentType": lookupMimeType(file.path) ?? 'application/octet-stream', "fileSize": 0,
"fileSize": await file.length(), "description": "",
"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!; final type = selectedExpenseType.value!;
return { return {
if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId, if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId,
"projectId": projectsMap[selectedProject.value]!, "projectId": projectsMap[selectedProject.value]!,
"expensesTypeId": type.id, "expensesTypeId": type.id,
"paymentModeId": selectedPaymentMode.value!.id, "paymentModeId": selectedPaymentMode.value!.id,
"paidById": selectedPaidBy.value!.id, "paidById": selectedPaidBy.value!.id,
"transactionDate": (selectedTransactionDate.value?.toUtc() ?? now.toUtc()) "transactionDate":
.toIso8601String(), (selectedTransactionDate.value ?? now).toUtc().toIso8601String(),
"transactionId": transactionIdController.text, "transactionId": transactionIdController.text,
"description": descriptionController.text, "description": descriptionController.text,
"location": locationController.text, "location": locationController.text,
@ -391,9 +453,11 @@ class AddExpenseController extends GetxController {
? int.tryParse(noOfPersonsController.text.trim()) ?? 0 ? int.tryParse(noOfPersonsController.text.trim()) ?? 0
: 0, : 0,
"billAttachments": [ "billAttachments": [
...existingAttachmentPayloads, ...existingPayload,
...newAttachmentPayloads ...newPayload,
], ].isEmpty
? null
: [...existingPayload, ...newPayload],
}; };
} }
@ -407,28 +471,27 @@ class AddExpenseController extends GetxController {
if (amountController.text.trim().isEmpty) missing.add("Amount"); if (amountController.text.trim().isEmpty) missing.add("Amount");
if (descriptionController.text.trim().isEmpty) missing.add("Description"); if (descriptionController.text.trim().isEmpty) missing.add("Description");
// Date Required if (selectedTransactionDate.value == null) {
if (selectedTransactionDate.value == null) missing.add("Transaction Date"); missing.add("Transaction Date");
if (selectedTransactionDate.value != null && } else if (selectedTransactionDate.value!.isAfter(DateTime.now())) {
selectedTransactionDate.value!.isAfter(DateTime.now())) {
missing.add("Valid Transaction Date"); missing.add("Valid Transaction Date");
} }
final amount = double.tryParse(amountController.text.trim()); if (double.tryParse(amountController.text.trim()) == null) {
if (amount == null) missing.add("Valid Amount"); missing.add("Valid Amount");
}
// Attachment: at least one required at all times final hasActiveExisting =
bool hasActiveExisting =
existingAttachments.any((e) => e['isActive'] != false); existingAttachments.any((e) => e['isActive'] != false);
if (attachments.isEmpty && !hasActiveExisting) missing.add("Attachment"); if (attachments.isEmpty && !hasActiveExisting) {
missing.add("Attachment");
}
return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}."; return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}.";
} }
// --- Snackbar Helper --- // --- Snackbar Helper ---
void _errorSnackbar(String msg, [String title = "Error"]) => showAppSnackbar( void _errorSnackbar(String msg, [String title = "Error"]) {
title: title, showAppSnackbar(title: title, message: msg, type: SnackbarType.error);
message: msg, }
type: SnackbarType.error,
);
} }

View File

@ -2,7 +2,7 @@ import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/model/expense/expense_detail_model.dart'; import 'package:marco/model/expense/expense_detail_model.dart';
import 'package:marco/model/employee_model.dart'; import 'package:marco/model/employees/employee_model.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ExpenseDetailController extends GetxController { class ExpenseDetailController extends GetxController {

View File

@ -6,7 +6,7 @@ import 'package:marco/model/expense/expense_list_model.dart';
import 'package:marco/model/expense/payment_types_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_type_model.dart';
import 'package:marco/model/expense/expense_status_model.dart'; import 'package:marco/model/expense/expense_status_model.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/helpers/widgets/my_snackbar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

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/app_logger.dart';
import 'package:marco/helpers/services/permission_service.dart'; import 'package:marco/helpers/services/permission_service.dart';
import 'package:marco/model/user_permission.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/model/projects_model.dart';
class PermissionController extends GetxController { class PermissionController extends GetxController {
@ -13,6 +13,7 @@ class PermissionController extends GetxController {
var employeeInfo = Rxn<EmployeeInfo>(); var employeeInfo = Rxn<EmployeeInfo>();
var projectsInfo = <ProjectInfo>[].obs; var projectsInfo = <ProjectInfo>[].obs;
Timer? _refreshTimer; Timer? _refreshTimer;
var isLoading = true.obs;
@override @override
void onInit() { void onInit() {
@ -26,7 +27,8 @@ class PermissionController extends GetxController {
await loadData(token!); await loadData(token!);
_startAutoRefresh(); _startAutoRefresh();
} else { } else {
logSafe("Token is null or empty. Skipping API load and auto-refresh.", level: LogLevel.warning); logSafe("Token is null or empty. Skipping API load and auto-refresh.",
level: LogLevel.warning);
} }
} }
@ -37,19 +39,24 @@ class PermissionController extends GetxController {
logSafe("Auth token retrieved: $token", level: LogLevel.debug); logSafe("Auth token retrieved: $token", level: LogLevel.debug);
return token; return token;
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error retrieving auth token", level: LogLevel.error, error: e, stackTrace: stacktrace); logSafe("Error retrieving auth token",
level: LogLevel.error, error: e, stackTrace: stacktrace);
return null; return null;
} }
} }
Future<void> loadData(String token) async { Future<void> loadData(String token) async {
try { try {
isLoading.value = true;
final userData = await PermissionService.fetchAllUserData(token); final userData = await PermissionService.fetchAllUserData(token);
_updateState(userData); _updateState(userData);
await _storeData(); await _storeData();
logSafe("Data loaded and state updated successfully."); logSafe("Data loaded and state updated successfully.");
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error loading data from API", level: LogLevel.error, error: e, stackTrace: stacktrace); logSafe("Error loading data from API",
level: LogLevel.error, error: e, stackTrace: stacktrace);
} finally {
isLoading.value = false;
} }
} }
@ -60,7 +67,8 @@ class PermissionController extends GetxController {
projectsInfo.assignAll(userData['projects']); projectsInfo.assignAll(userData['projects']);
logSafe("State updated with user data."); logSafe("State updated with user data.");
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error updating state", level: LogLevel.error, error: e, stackTrace: stacktrace); logSafe("Error updating state",
level: LogLevel.error, error: e, stackTrace: stacktrace);
} }
} }
@ -89,7 +97,8 @@ class PermissionController extends GetxController {
logSafe("User data successfully stored in SharedPreferences."); logSafe("User data successfully stored in SharedPreferences.");
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error storing data", level: LogLevel.error, error: e, stackTrace: stacktrace); logSafe("Error storing data",
level: LogLevel.error, error: e, stackTrace: stacktrace);
} }
} }
@ -100,20 +109,23 @@ class PermissionController extends GetxController {
if (token?.isNotEmpty ?? false) { if (token?.isNotEmpty ?? false) {
await loadData(token!); await loadData(token!);
} else { } else {
logSafe("Token missing during auto-refresh. Skipping.", level: LogLevel.warning); logSafe("Token missing during auto-refresh. Skipping.",
level: LogLevel.warning);
} }
}); });
} }
bool hasPermission(String permissionId) { bool hasPermission(String permissionId) {
final hasPerm = permissions.any((p) => p.id == permissionId); final hasPerm = permissions.any((p) => p.id == permissionId);
logSafe("Checking permission $permissionId: $hasPerm", level: LogLevel.debug); logSafe("Checking permission $permissionId: $hasPerm",
level: LogLevel.debug);
return hasPerm; return hasPerm;
} }
bool isUserAssignedToProject(String projectId) { bool isUserAssignedToProject(String projectId) {
final assigned = projectsInfo.any((project) => project.id == projectId); final assigned = projectsInfo.any((project) => project.id == projectId);
logSafe("Checking project assignment for $projectId: $assigned", level: LogLevel.debug); logSafe("Checking project assignment for $projectId: $assigned",
level: LogLevel.debug);
return assigned; return assigned;
} }

View File

@ -1,207 +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 = [];
RxBool isAssigningTask = false.obs;
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 {
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,
);
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;
}
}
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/services/api_service.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_snackbar.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 { class AddTaskController extends GetxController {
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs; 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/helpers/services/app_logger.dart';
import 'package:marco/controller/my_controller.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/services/api_service.dart';
import 'package:marco/helpers/widgets/my_form_validator.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_image_compressor.dart';
import 'package:marco/helpers/widgets/my_snackbar.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 } enum ApiStatus { idle, loading, success, failure }
@ -34,7 +34,7 @@ class ReportTaskActionController extends MyController {
final RxString selectedWorkStatusName = ''.obs; final RxString selectedWorkStatusName = ''.obs;
final MyFormValidator basicValidator = MyFormValidator(); final MyFormValidator basicValidator = MyFormValidator();
final DailyTaskPlaningController taskController = Get.put(DailyTaskPlaningController()); final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController());
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
final assignedDateController = TextEditingController(); final assignedDateController = TextEditingController();

View File

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

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,14 +1,20 @@
class ApiEndpoints { class ApiEndpoints {
// static const String baseUrl = "https://stageapi.marcoaiot.com/api"; static const String baseUrl = "https://stageapi.marcoaiot.com/api";
static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
// Dashboard Module API Endpoints // Dashboard Module API Endpoints
static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview"; 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 Module API Endpoints // Attendance Module API Endpoints
static const String getProjects = "/project/list"; static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic"; static const String getGlobalProjects = "/project/list/basic";
static const String getEmployeesByProject = "/attendance/project/team"; static const String getTodaysAttendance = "/attendance/project/team";
static const String getAttendanceLogs = "/attendance/project/log"; static const String getAttendanceLogs = "/attendance/project/log";
static const String getAttendanceLogView = "/attendance/log/attendance"; static const String getAttendanceLogView = "/attendance/log/attendance";
static const String getRegularizationLogs = "/attendance/regularize"; static const String getRegularizationLogs = "/attendance/regularize";
@ -16,10 +22,11 @@ class ApiEndpoints {
// Employee Screen API Endpoints // Employee Screen API Endpoints
static const String getAllEmployeesByProject = "/employee/list"; static const String getAllEmployeesByProject = "/employee/list";
static const String getAllEmployeesByOrganization = "/project/get/task/team";
static const String getAllEmployees = "/employee/list"; static const String getAllEmployees = "/employee/list";
static const String getEmployeesWithoutPermission = "/employee/basic"; static const String getEmployeesWithoutPermission = "/employee/basic";
static const String getRoles = "/roles/jobrole"; static const String getRoles = "/roles/jobrole";
static const String createEmployee = "/employee/manage-mobile"; static const String createEmployee = "/employee/app/manage";
static const String getEmployeeInfo = "/employee/profile/get"; static const String getEmployeeInfo = "/employee/profile/get";
static const String assignEmployee = "/employee/profile/get"; static const String assignEmployee = "/employee/profile/get";
static const String getAssignedProjects = "/project/assigned-projects"; static const String getAssignedProjects = "/project/assigned-projects";
@ -35,16 +42,20 @@ class ApiEndpoints {
static const String approveReportAction = "/task/approve"; static const String approveReportAction = "/task/approve";
static const String assignTask = "/project/task"; static const String assignTask = "/project/task";
static const String getmasterWorkCategories = "/Master/work-categories"; static const String getmasterWorkCategories = "/Master/work-categories";
static const String getDailyTaskProjectProgressFilter = "/task/filter";
////// Directory Module API Endpoints /////// ////// Directory Module API Endpoints ///////
static const String getDirectoryContacts = "/directory"; static const String getDirectoryContacts = "/directory";
static const String getDirectoryBucketList = "/directory/buckets"; static const String getDirectoryBucketList = "/directory/buckets";
static const String getDirectoryContactDetail = "/directory/notes"; static const String getDirectoryContactDetail = "/directory/notes";
static const String getDirectoryContactCategory = "/master/contact-categories"; static const String getDirectoryContactCategory =
"/master/contact-categories";
static const String getDirectoryContactTags = "/master/contact-tags"; static const String getDirectoryContactTags = "/master/contact-tags";
static const String getDirectoryOrganization = "/directory/organization"; static const String getDirectoryOrganization = "/directory/organization";
static const String createContact = "/directory"; static const String createContact = "/directory";
static const String updateContact = "/directory"; static const String updateContact = "/directory";
static const String deleteContact = "/directory";
static const String restoreContact = "/directory/note";
static const String getDirectoryNotes = "/directory/notes"; static const String getDirectoryNotes = "/directory/notes";
static const String updateDirectoryNotes = "/directory/note"; static const String updateDirectoryNotes = "/directory/note";
static const String createBucket = "/directory/bucket"; static const String createBucket = "/directory/bucket";
@ -62,4 +73,31 @@ class ApiEndpoints {
static const String getMasterExpenseTypes = "/master/expenses-types"; static const String getMasterExpenseTypes = "/master/expenses-types";
static const String updateExpenseStatus = "/expense/action"; static const String updateExpenseStatus = "/expense/action";
static const String deleteExpense = "/expense/delete"; 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,79 +1,30 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/theme/theme_customizer.dart';
import 'package:marco/helpers/theme/app_theme.dart';
import 'package:url_strategy/url_strategy.dart'; import 'package:url_strategy/url_strategy.dart';
import 'package: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/app_logger.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:marco/helpers/services/auth_service.dart';
import 'package:flutter/material.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 { Future<void> initializeApp() async {
try { try {
logSafe("💡 Starting app initialization..."); logSafe("💡 Starting app initialization...");
// UI Setup await Future.wait([
setPathUrlStrategy(); _setupUI(),
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); _setupFirebase(),
SystemChrome.setSystemUIOverlayStyle( _setupLocalStorage(),
const SystemUiOverlayStyle( ]);
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarIconBrightness: Brightness.dark,
),
);
logSafe("💡 UI setup completed.");
// Local storage await _setupDeviceInfo();
await LocalStorage.init(); await _handleAuthTokens();
logSafe("💡 Local storage initialized."); await _setupTheme();
await _setupFirebaseMessaging();
// Token handling _finalizeAppStyle();
final refreshToken = await LocalStorage.getRefreshToken();
final hasRefreshToken = refreshToken?.isNotEmpty ?? false;
if (hasRefreshToken) {
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 or handle logout here
}
} else {
logSafe("❌ No refresh token found. Skipping refresh.");
}
// Theme setup
await ThemeCustomizer.init();
logSafe("💡 Theme customizer initialized.");
// Controller setup
final token = LocalStorage.getString('jwt_token');
final hasJwt = token?.isNotEmpty ?? false;
if (hasJwt) {
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
logSafe("💡 PermissionController injected.");
}
if (!Get.isRegistered<ProjectController>()) {
Get.put(ProjectController(), permanent: true);
logSafe("💡 ProjectController injected as permanent.");
}
await Get.find<PermissionController>().loadData(token!);
await Get.find<ProjectController>().fetchProjects();
} else {
logSafe("⚠️ No valid JWT token found. Skipping controller initialization.");
}
// Final style setup
AppStyle.init();
logSafe("💡 AppStyle initialized.");
logSafe("✅ App initialization completed successfully."); logSafe("✅ App initialization completed successfully.");
} catch (e, stacktrace) { } catch (e, stacktrace) {
@ -86,3 +37,57 @@ Future<void> initializeApp() async {
rethrow; rethrow;
} }
} }
Future<void> _handleAuthTokens() async {
final refreshToken = await LocalStorage.getRefreshToken();
if (refreshToken?.isNotEmpty ?? false) {
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
final success = await AuthService.refreshToken();
if (!success) {
logSafe("⚠️ Refresh token invalid or expired. User must login again.");
}
} else {
logSafe("❌ No refresh token found. Skipping refresh.");
}
}
Future<void> _setupUI() async {
setPathUrlStrategy();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
logSafe("💡 UI setup completed with default system behavior.");
}
Future<void> _setupFirebase() async {
await Firebase.initializeApp();
logSafe("💡 Firebase initialized.");
}
Future<void> _setupLocalStorage() async {
if (!LocalStorage.isInitialized) {
await LocalStorage.init();
logSafe("💡 Local storage initialized.");
} else {
logSafe(" Local storage already initialized, skipping.");
}
}
Future<void> _setupDeviceInfo() async {
final deviceInfoService = DeviceInfoService();
await deviceInfoService.init();
logSafe("📱 Device Info: ${deviceInfoService.deviceData}");
}
Future<void> _setupTheme() async {
await ThemeCustomizer.init();
logSafe("💡 Theme customizer initialized.");
}
Future<void> _setupFirebaseMessaging() async {
await FirebaseNotificationService().initialize();
logSafe("💡 Firebase Messaging initialized.");
}
void _finalizeAppStyle() {
AppStyle.init();
logSafe("💡 AppStyle initialized.");
}

View File

@ -2,16 +2,41 @@ import 'dart:io';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:marco/helpers/services/api_service.dart';
/// Global logger instance /// Global logger instance
late final Logger appLogger; Logger? _appLogger;
late final FileLogOutput fileLogOutput; late final FileLogOutput _fileLogOutput;
/// Store logs temporarily for API posting
final List<Map<String, dynamic>> _logBuffer = [];
/// 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 /// Initialize logging
Future<void> initLogging() async { Future<void> initLogging() async {
fileLogOutput = FileLogOutput(); _fileLogOutput = FileLogOutput();
appLogger = Logger( _appLogger = Logger(
printer: PrettyPrinter( printer: PrettyPrinter(
methodCount: 0, methodCount: 0,
printTime: true, printTime: true,
@ -20,12 +45,18 @@ Future<void> initLogging() async {
), ),
output: MultiOutput([ output: MultiOutput([
ConsoleOutput(), ConsoleOutput(),
fileLogOutput, _fileLogOutput,
]), ]),
level: Level.debug, level: Level.debug,
); );
} }
/// Enable API posting after login
void enableRemoteLogging() {
_canPostLogs = true;
_postBufferedLogs(); // flush logs if any
}
/// Safe logger wrapper /// Safe logger wrapper
void logSafe( void logSafe(
String message, { String message, {
@ -34,27 +65,60 @@ void logSafe(
StackTrace? stackTrace, StackTrace? stackTrace,
bool sensitive = false, bool sensitive = false,
}) { }) {
if (sensitive) return; if (sensitive || _appLogger == null) return;
switch (level) { final loggerLevel = _levelMap[level] ?? Level.info;
case LogLevel.debug: _appLogger!.log(loggerLevel, message, error: error, stackTrace: stackTrace);
appLogger.d(message, error: error, stackTrace: stackTrace);
break; // Buffer logs for API posting
case LogLevel.warning: _logBuffer.add({
appLogger.w(message, error: error, stackTrace: stackTrace); "logLevel": level.name,
break; "message": message,
case LogLevel.error: "timeStamp": DateTime.now().toUtc().toIso8601String(),
appLogger.e(message, error: error, stackTrace: stackTrace); "ipAddress": "this is test IP", // TODO: real IP
break; "userAgent": "FlutterApp/1.0", // TODO: device_info_plus
case LogLevel.verbose: "details": error?.toString() ?? stackTrace?.toString(),
appLogger.v(message, error: error, stackTrace: stackTrace); });
break;
default: if (_logBuffer.length >= _maxLogsBeforePost) {
appLogger.i(message, error: error, stackTrace: stackTrace); _postBufferedLogs();
} }
} }
/// Log output to file (safe path, no permission required) /// Post buffered logs to API
Future<void> _postBufferedLogs() async {
if (!_canPostLogs) return; // 🚫 skip if not logged in
if (_isPosting || _logBuffer.isEmpty) return;
_isPosting = true;
final logsToSend = List<Map<String, dynamic>>.from(_logBuffer);
_logBuffer.clear();
try {
final success = await ApiService.postLogsApi(logsToSend);
if (!success) {
_reinsertLogs(logsToSend, reason: "API call returned false");
}
} catch (e) {
_reinsertLogs(logsToSend, reason: "API exception: $e");
} finally {
_isPosting = false;
}
}
/// Reinsert logs into buffer if posting fails
void _reinsertLogs(List<Map<String, dynamic>> logs, {required String reason}) {
_appLogger?.w("Failed to post logs, re-queuing. Reason: $reason");
if (_logBuffer.length + logs.length > _maxBufferSize) {
_appLogger?.e("Buffer full. Dropping ${logs.length} logs to prevent crash.");
return;
}
_logBuffer.insertAll(0, logs);
}
/// File-based log output (safe storage)
class FileLogOutput extends LogOutput { class FileLogOutput extends LogOutput {
File? _logFile; File? _logFile;
@ -81,7 +145,6 @@ class FileLogOutput extends LogOutput {
@override @override
void output(OutputEvent event) async { void output(OutputEvent event) async {
await _init(); await _init();
if (event.lines.isEmpty) return; if (event.lines.isEmpty) return;
final logMessage = event.lines.join('\n') + '\n'; final logMessage = event.lines.join('\n') + '\n';
@ -122,22 +185,5 @@ class FileLogOutput extends LogOutput {
} }
} }
/// Simple log printer for file output /// Custom log levels
class SimpleFileLogPrinter extends LogPrinter {
@override
List<String> log(LogEvent event) {
final message = event.message.toString();
if (message.contains('[SENSITIVE]')) return [];
final timestamp = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
final level = event.level.name.toUpperCase();
final error = event.error != null ? ' | ERROR: ${event.error}' : '';
final stack =
event.stackTrace != null ? '\nSTACKTRACE:\n${event.stackTrace}' : '';
return ['[$timestamp] [$level] $message$error$stack'];
}
}
/// Optional enum for log levels
enum LogLevel { debug, info, warning, error, verbose } enum LogLevel { debug, info, warning, error, verbose }

View File

@ -1,9 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:get/get.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package: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/api_endpoints.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
@ -15,278 +11,282 @@ class AuthService {
}; };
static bool isLoggedIn = false; static bool isLoggedIn = false;
/* -------------------------------------------------------------------------- */
/// Login with email and password /* Logout API */
static Future<Map<String, String>?> loginUser(Map<String, dynamic> data) async { /* -------------------------------------------------------------------------- */
static Future<bool> logoutApi(String refreshToken, String fcmToken) async {
try { try {
logSafe("Attempting login..."); final body = {
final response = await http.post( "refreshToken": refreshToken,
Uri.parse("$_baseUrl/auth/login-mobile"), "fcmToken": fcmToken,
headers: _headers, };
body: jsonEncode(data),
);
final responseData = jsonDecode(response.body); final response = await _post("/auth/logout", body);
if (response.statusCode == 200 && responseData['data'] != null) {
await _handleLoginSuccess(responseData['data']); if (response != null && response['statusCode'] == 200) {
return null; logSafe("✅ Logout API successful");
} else if (response.statusCode == 401) { return true;
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"};
} }
} catch (e, stacktrace) {
logSafe("Login exception", level: LogLevel.error, error: e, stackTrace: stacktrace); logSafe("⚠️ Logout API failed: ${response?['message']}",
return {"error": "Network error. Please check your connection."}; level: LogLevel.warning);
return false;
} catch (e, st) {
_handleError("Logout API error", e, st);
return false;
} }
} }
/// Refresh JWT token /* -------------------------------------------------------------------------- */
static Future<bool> refreshToken() async { /* Public Methods */
final accessToken = await LocalStorage.getJwtToken(); /* -------------------------------------------------------------------------- */
final refreshToken = await LocalStorage.getRefreshToken();
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); logSafe("Missing access or refresh token.", level: LogLevel.warning);
return false; return false;
} }
final requestBody = { final body = {"token": accessToken, "refreshToken": refreshToken};
"token": accessToken, final data = await _post("/auth/refresh-token", body);
"refreshToken": refreshToken, if (data != null && data['success'] == true) {
}; await LocalStorage.setJwtToken(data['data']['token']);
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
await LocalStorage.setLoggedInUser(true);
logSafe("Token refreshed successfully.");
try { // 🔹 Retry FCM token registration after token refresh
logSafe("Refreshing token..."); final newFcmToken = LocalStorage.getFcmToken();
final response = await http.post( if (newFcmToken?.isNotEmpty ?? false) {
Uri.parse("$_baseUrl/auth/refresh-token"), final success = await registerDeviceToken(newFcmToken!);
headers: _headers, logSafe(
body: jsonEncode(requestBody), success
); ? "✅ FCM token re-registered after JWT refresh."
: "⚠️ Failed to register FCM token after JWT refresh.",
final data = jsonDecode(response.body); level: success ? LogLevel.info : LogLevel.warning);
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;
} }
} catch (e, stacktrace) {
logSafe("Token refresh exception", level: LogLevel.error, error: e, stackTrace: stacktrace); return true;
return false;
} }
logSafe("Refresh token failed: ${data?['message']}",
level: LogLevel.warning);
return false;
} }
/// Forgot password static Future<Map<String, String>?> forgotPassword(String email) =>
static Future<Map<String, String>?> forgotPassword(String email) async { _wrapErrorHandling(() => _post("/auth/forgot-password", {"email": email}),
try { successCondition: (data) => data['success'] == true,
logSafe("Forgot password requested."); defaultError: "Failed to send reset link.");
final response = await http.post(
Uri.parse("$_baseUrl/auth/forgot-password"),
headers: _headers,
body: jsonEncode({"email": email}),
);
final data = jsonDecode(response.body); static Future<Map<String, String>?> requestDemo(
if (response.statusCode == 200 && data['success'] == true) return null; Map<String, dynamic> demoData) =>
return {"error": data['message'] ?? "Failed to send reset link."}; _wrapErrorHandling(() => _post("/market/inquiry", demoData),
} catch (e, stacktrace) { successCondition: (data) => data['success'] == true,
logSafe("Forgot password error", level: LogLevel.error, error: e, stackTrace: stacktrace); defaultError: "Failed to submit demo request.");
return {"error": "Network error. Please check your connection."};
}
}
/// Request demo
static Future<Map<String, String>?> requestDemo(Map<String, dynamic> demoData) async {
try {
logSafe("Submitting demo request...");
final response = await http.post(
Uri.parse("$_baseUrl/market/inquiry"),
headers: _headers,
body: jsonEncode(demoData),
);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) return null;
return {"error": data['message'] ?? "Failed to submit demo request."};
} catch (e, stacktrace) {
logSafe("Request demo error", level: LogLevel.error, error: e, stackTrace: stacktrace);
return {"error": "Network error. Please check your connection."};
}
}
/// Get list of industries
static Future<List<Map<String, dynamic>>?> getIndustries() async { static Future<List<Map<String, dynamic>>?> getIndustries() async {
try { final data = await _get("/market/industries");
logSafe("Fetching industries list..."); if (data != null && data['success'] == true) {
final response = await http.get( return List<Map<String, dynamic>>.from(data['data']);
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;
} }
return null;
} }
/// Generate MPIN
static Future<Map<String, String>?> generateMpin({ static Future<Map<String, String>?> generateMpin({
required String employeeId, required String employeeId,
required String mpin, required String mpin,
}) async { }) =>
final token = await LocalStorage.getJwtToken(); _wrapErrorHandling(
logSafe("Generating MPIN for employeeId: $employeeId"); () async {
logSafe("MPIN: $mpin"); final token = LocalStorage.getJwtToken();
try { return _post(
logSafe("Generating MPIN..."); "/auth/generate-mpin",
final response = await http.post( {"employeeId": employeeId, "mpin": mpin},
Uri.parse("$_baseUrl/auth/generate-mpin"), authToken: token,
headers: { );
..._headers,
if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token',
}, },
body: jsonEncode({"employeeId": employeeId, "mpin": mpin}), successCondition: (data) => data['success'] == true,
defaultError: "Failed to generate MPIN.",
); );
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) return null;
return {"error": data['message'] ?? "Failed to generate MPIN."};
} catch (e, stacktrace) {
logSafe("Generate MPIN error", level: LogLevel.error, error: e, stackTrace: stacktrace);
return {"error": "Network error. Please check your connection."};
}
}
/// Verify MPIN
static Future<Map<String, String>?> verifyMpin({ static Future<Map<String, String>?> verifyMpin({
required String mpin, required String mpin,
required String mpinToken, required String mpinToken,
}) async { required String fcmToken,
final employeeInfo = LocalStorage.getEmployeeInfo(); }) =>
if (employeeInfo == null) return {"error": "Employee info not found."}; _wrapErrorHandling(
() async {
final token = await LocalStorage.getJwtToken(); final employeeInfo = LocalStorage.getEmployeeInfo();
if (employeeInfo == null) return null;
try { final token = await LocalStorage.getJwtToken();
logSafe("Verifying MPIN..."); return _post(
final response = await http.post( "/auth/login-mpin",
Uri.parse("$_baseUrl/auth/login-mpin"), {
headers: { "employeeId": employeeInfo.id,
..._headers, "mpin": mpin,
if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token', "mpinToken": mpinToken,
"fcmToken": fcmToken,
},
authToken: token,
);
}, },
body: jsonEncode({ successCondition: (data) => data['success'] == true,
"employeeId": employeeInfo.id, defaultError: "MPIN verification failed.",
"mpin": mpin,
"mpinToken": mpinToken,
}),
); );
final data = jsonDecode(response.body); static Future<Map<String, String>?> generateOtp(String email) =>
if (response.statusCode == 200 && data['success'] == true) return null; _wrapErrorHandling(() => _post("/auth/send-otp", {"email": email}),
return {"error": data['message'] ?? "MPIN verification failed."}; successCondition: (data) => data['success'] == true,
} catch (e, stacktrace) { defaultError: "Failed to generate OTP.");
logSafe("Verify MPIN error", level: LogLevel.error, error: e, stackTrace: stacktrace);
return {"error": "Network error. Please check your connection."};
}
}
/// Generate OTP
static Future<Map<String, String>?> generateOtp(String email) async {
try {
logSafe("Generating OTP for email...");
final response = await http.post(
Uri.parse("$_baseUrl/auth/send-otp"),
headers: _headers,
body: jsonEncode({"email": email}),
);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) return null;
return {"error": data['message'] ?? "Failed to generate OTP."};
} catch (e, stacktrace) {
logSafe("Generate OTP error", level: LogLevel.error, error: e, stackTrace: stacktrace);
return {"error": "Network error. Please check your connection."};
}
}
/// Verify OTP and login
static Future<Map<String, String>?> verifyOtp({ static Future<Map<String, String>?> verifyOtp({
required String email, required String email,
required String otp, required String otp,
}) async { }) async {
try { final data = await _post("/auth/login-otp", {"email": email, "otp": otp});
logSafe("Verifying OTP..."); if (data != null && data['data'] != null) {
final response = await http.post( await _handleLoginSuccess(data['data']);
Uri.parse("$_baseUrl/auth/login-otp"), return null;
headers: _headers, }
body: jsonEncode({"email": email, "otp": otp}), return {"error": data?['message'] ?? "OTP verification failed."};
); }
final data = jsonDecode(response.body); /* -------------------------------------------------------------------------- */
if (response.statusCode == 200 && data['data'] != null) { /* Private Utilities */
await _handleLoginSuccess(data['data']); /* -------------------------------------------------------------------------- */
return null;
} static Future<Map<String, dynamic>?> _post(
return {"error": data['message'] ?? "OTP verification failed."}; String path,
} catch (e, stacktrace) { Map<String, dynamic> body, {
logSafe("Verify OTP error", level: LogLevel.error, error: e, stackTrace: stacktrace); String? authToken,
return {"error": "Network error. Please check your connection."}; }) async {
try {
final headers = {
..._headers,
if (authToken?.isNotEmpty ?? false)
'Authorization': 'Bearer $authToken',
};
final response = await http.post(Uri.parse("$_baseUrl$path"),
headers: headers, body: jsonEncode(body));
return {
...jsonDecode(response.body),
"statusCode": response.statusCode,
};
} catch (e, st) {
_handleError("$path POST error", e, st);
return null;
} }
} }
/// Handle login success flow static Future<Map<String, dynamic>?> _get(
static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async { String path, {
logSafe("Processing login success..."); String? authToken,
}) async {
final jwtToken = data['token']; try {
final refreshToken = data['refreshToken']; final headers = {
final mpinToken = data['mpinToken']; ..._headers,
if (authToken?.isNotEmpty ?? false)
// Save tokens 'Authorization': 'Bearer $authToken',
await LocalStorage.setJwtToken(jwtToken); };
await LocalStorage.setLoggedInUser(true); final response =
await http.get(Uri.parse("$_baseUrl$path"), headers: headers);
if (refreshToken != null) { return {
await LocalStorage.setRefreshToken(refreshToken); ...jsonDecode(response.body),
"statusCode": response.statusCode,
};
} catch (e, st) {
_handleError("$path GET error", e, st);
return null;
}
} }
if (mpinToken != null && mpinToken.isNotEmpty) { static Future<Map<String, String>?> _wrapErrorHandling(
await LocalStorage.setMpinToken(mpinToken); Future<Map<String, dynamic>?> Function() request, {
await LocalStorage.setIsMpin(true); required bool Function(Map<String, dynamic> data) successCondition,
} else { required String defaultError,
await LocalStorage.setIsMpin(false); }) async {
await LocalStorage.removeMpinToken(); final data = await request();
if (data != null && successCondition(data)) return null;
return {"error": data?['message'] ?? defaultError};
} }
// Inject controllers if not already registered static void _handleError(String message, Object error, StackTrace st) {
if (!Get.isRegistered<PermissionController>()) { logSafe(message, level: LogLevel.error, error: error, stackTrace: st);
Get.put(PermissionController());
logSafe("✅ PermissionController injected after login.");
} }
if (!Get.isRegistered<ProjectController>()) { static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async {
Get.put(ProjectController(), permanent: true); logSafe("Processing login success...");
logSafe("✅ ProjectController injected after login.");
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/helpers/services/app_logger.dart';
import 'package:marco/model/user_permission.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/model/projects_model.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/api_endpoints.dart'; import 'package:marco/helpers/services/api_endpoints.dart';
class PermissionService { class PermissionService {
// In-memory cache keyed by user token
static final Map<String, Map<String, dynamic>> _userDataCache = {}; static final Map<String, Map<String, dynamic>> _userDataCache = {};
static const String _baseUrl = ApiEndpoints.baseUrl; static const String _baseUrl = ApiEndpoints.baseUrl;
/// Fetches all user-related data (permissions, employee info, projects) /// Fetches all user-related data (permissions, employee info, projects).
/// Uses in-memory cache for repeated token queries during session.
static Future<Map<String, dynamic>> fetchAllUserData( static Future<Map<String, dynamic>> fetchAllUserData(
String token, { String token, {
bool hasRetried = false, bool hasRetried = false,
}) async { }) async {
logSafe("Fetching user data...", ); logSafe("Fetching user data...");
if (_userDataCache.containsKey(token)) { // Check for cached data before network request
logSafe("User data cache hit.", ); final cached = _userDataCache[token];
return _userDataCache[token]!; if (cached != null) {
logSafe("User data cache hit.");
return cached;
} }
final uri = Uri.parse("$_baseUrl/user/profile"); final uri = Uri.parse("$_baseUrl/user/profile");
@ -34,8 +38,8 @@ class PermissionService {
final statusCode = response.statusCode; final statusCode = response.statusCode;
if (statusCode == 200) { if (statusCode == 200) {
logSafe("User data fetched successfully."); final raw = json.decode(response.body);
final data = json.decode(response.body)['data']; final data = raw['data'] as Map<String, dynamic>;
final result = { final result = {
'permissions': _parsePermissions(data['featurePermissions']), 'permissions': _parsePermissions(data['featurePermissions']),
@ -43,10 +47,12 @@ class PermissionService {
'projects': _parseProjectsInfo(data['projects']), 'projects': _parseProjectsInfo(data['projects']),
}; };
_userDataCache[token] = result; _userDataCache[token] = result; // Cache it for future use
logSafe("User data fetched successfully.");
return result; return result;
} }
// Token expired, try refresh once then redirect on failure
if (statusCode == 401 && !hasRetried) { if (statusCode == 401 && !hasRetried) {
logSafe("Unauthorized. Attempting token refresh...", level: LogLevel.warning); logSafe("Unauthorized. Attempting token refresh...", level: LogLevel.warning);
@ -63,42 +69,43 @@ class PermissionService {
throw Exception('Unauthorized. Token refresh failed.'); throw Exception('Unauthorized. Token refresh failed.');
} }
final error = json.decode(response.body)['message'] ?? 'Unknown error'; final errorMsg = json.decode(response.body)['message'] ?? 'Unknown error';
logSafe("Failed to fetch user data: $error", level: LogLevel.warning); logSafe("Failed to fetch user data: $errorMsg", level: LogLevel.warning);
throw Exception('Failed to fetch user data: $error'); throw Exception('Failed to fetch user data: $errorMsg');
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Exception while fetching user data", level: LogLevel.error, error: e, stackTrace: stacktrace); logSafe("Exception while fetching user data", level: LogLevel.error, error: e, stackTrace: stacktrace);
rethrow; rethrow; // Let the caller handle or report
} }
} }
/// Clears auth data and redirects to login /// Handles unauthorized/user sign out flow
static Future<void> _handleUnauthorized() async { static Future<void> _handleUnauthorized() async {
logSafe("Clearing tokens and redirecting to login due to unauthorized access.", level: LogLevel.warning); logSafe("Clearing tokens and redirecting to login due to unauthorized access.", level: LogLevel.warning);
await LocalStorage.removeToken('jwt_token'); await LocalStorage.removeToken('jwt_token');
await LocalStorage.removeToken('refresh_token'); await LocalStorage.removeToken('refresh_token');
await LocalStorage.setLoggedInUser(false); await LocalStorage.setLoggedInUser(false);
Get.offAllNamed('/auth/login-option'); Get.offAllNamed('/auth/login-option');
} }
/// Converts raw permission data into list of `UserPermission` /// Robust model parsing for permissions
static List<UserPermission> _parsePermissions(List<dynamic> permissions) { static List<UserPermission> _parsePermissions(List<dynamic> permissions) {
logSafe("Parsing user permissions..."); logSafe("Parsing user permissions...");
return permissions return permissions
.map((id) => UserPermission.fromJson({'id': id})) .map((perm) => UserPermission.fromJson({'id': perm}))
.toList(); .toList();
} }
/// Converts raw employee JSON into `EmployeeInfo` /// Robust model parsing for employee info
static EmployeeInfo _parseEmployeeInfo(Map<String, dynamic> data) { static EmployeeInfo _parseEmployeeInfo(Map<String, dynamic>? data) {
logSafe("Parsing employee info..."); logSafe("Parsing employee info...");
if (data == null) throw Exception("Employee data missing");
return EmployeeInfo.fromJson(data); return EmployeeInfo.fromJson(data);
} }
/// Converts raw projects JSON into list of `ProjectInfo` /// Robust model parsing for projects list
static List<ProjectInfo> _parseProjectsInfo(List<dynamic> projects) { static List<ProjectInfo> _parseProjectsInfo(List<dynamic>? projects) {
logSafe("Parsing projects info..."); logSafe("Parsing projects info...");
if (projects == null) return [];
return projects.map((proj) => ProjectInfo.fromJson(proj)).toList(); return projects.map((proj) => ProjectInfo.fromJson(proj)).toList();
} }
} }

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:marco/helpers/services/app_logger.dart';
class DateTimeUtils { class DateTimeUtils {
/// Converts a UTC datetime string to local time and formats it. /// Converts a UTC datetime string to local time and formats it.
static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) { static String convertUtcToLocal(String utcTimeString,
{String format = 'dd-MM-yyyy'}) {
try { try {
final parsed = DateTime.parse(utcTimeString); final parsed = DateTime.parse(utcTimeString);
final utcDateTime = DateTime.utc( final utcDateTime = DateTime.utc(
@ -18,12 +18,8 @@ class DateTimeUtils {
); );
final localDateTime = utcDateTime.toLocal(); final localDateTime = utcDateTime.toLocal();
return _formatDateTime(localDateTime, format: format);
final formatted = _formatDateTime(localDateTime, format: format); } catch (e) {
return formatted;
} catch (e, stackTrace) {
logSafe('DateTime conversion failed: $e', error: e, stackTrace: stackTrace);
return 'Invalid Date'; return 'Invalid Date';
} }
} }
@ -32,14 +28,23 @@ class DateTimeUtils {
static String formatDate(DateTime date, String format) { static String formatDate(DateTime date, String format) {
try { try {
return DateFormat(format).format(date); return DateFormat(format).format(date);
} catch (e, stackTrace) { } catch (e) {
logSafe('formatDate failed: $e', error: e, stackTrace: stackTrace);
return 'Invalid Date'; 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. /// Internal formatter with default format.
static String _formatDateTime(DateTime dateTime, {String format = 'dd-MM-yyyy'}) { static String _formatDateTime(DateTime dateTime,
{String format = 'dd-MM-yyyy'}) {
return DateFormat(format).format(dateTime); return DateFormat(format).format(dateTime);
} }
} }

View File

@ -1,4 +1,4 @@
/// Contains all role and permission UUIDs used for access control across the application. /// Contains all role, permission, and entity UUIDs used for access control across the application.
class Permissions { class Permissions {
// ------------------- Project Management ------------------------------ // ------------------- Project Management ------------------------------
/// Permission to manage master data (like dropdowns, configurations) /// Permission to manage master data (like dropdowns, configurations)
@ -91,4 +91,30 @@ class Permissions {
// ------------------- Application Roles ------------------------------- // ------------------- Application Roles -------------------------------
/// Application role ID for users with full expense management rights /// Application role ID for users with full expense management rights
static const String expenseManagement = "a4e25142-449b-4334-a6e5-22f70e4732d7"; static const String expenseManagement = "a4e25142-449b-4334-a6e5-22f70e4732d7";
// ------------------- Document Entities -------------------------------
/// 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

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

@ -6,9 +6,9 @@ import 'package:marco/controller/expense/expense_screen_controller.dart';
import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/expense/expense_list_model.dart'; import 'package:marco/model/expense/expense_list_model.dart';
import 'package:marco/view/expense/expense_detail_screen.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 { class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
final ProjectController projectController; final ProjectController projectController;
@ -251,99 +251,20 @@ class ExpenseList extends StatelessWidget {
void _showDeleteConfirmation(BuildContext context, ExpenseModel expense) { void _showDeleteConfirmation(BuildContext context, ExpenseModel expense) {
final ExpenseController controller = Get.find<ExpenseController>(); final ExpenseController controller = Get.find<ExpenseController>();
final RxBool isDeleting = false.obs;
showDialog( showDialog(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (_) => Dialog( builder: (_) => ConfirmDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), title: "Delete Expense",
child: Obx(() { message: "Are you sure you want to delete this draft expense?",
return Padding( confirmText: "Delete",
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), cancelText: "Cancel",
child: isDeleting.value icon: Icons.delete_forever,
? const SizedBox( confirmColor: Colors.redAccent,
height: 100, onConfirm: () async {
child: Center(child: CircularProgressIndicator()), await controller.deleteExpense(expense.id);
) },
: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.delete,
size: 48, color: Colors.redAccent),
const SizedBox(height: 16),
MyText.titleLarge("Delete Expense",
fontWeight: 600,
color: Theme.of(context).colorScheme.onBackground),
const SizedBox(height: 12),
MyText.bodySmall(
"Are you sure you want to delete this draft expense?",
textAlign: TextAlign.center,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.7),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => Navigator.pop(context),
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: 12),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () async {
isDeleting.value = true;
await controller.deleteExpense(expense.id);
isDeleting.value = false;
Navigator.pop(context);
showAppSnackbar(
title: 'Deleted',
message: 'Expense has been deleted.',
type: SnackbarType.success,
);
},
icon: const Icon(Icons.delete_forever,
color: Colors.white),
label: MyText.bodyMedium(
"Delete",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding:
const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
],
),
);
}),
), ),
); );
} }
@ -363,7 +284,7 @@ class ExpenseList extends StatelessWidget {
final expense = expenseList[index]; final expense = expenseList[index];
final formattedDate = DateTimeUtils.convertUtcToLocal( final formattedDate = DateTimeUtils.convertUtcToLocal(
expense.transactionDate.toIso8601String(), expense.transactionDate.toIso8601String(),
format: 'dd MMM yyyy, hh:mm a', format: 'dd MMM yyyy',
); );
return Material( return Material(
@ -391,7 +312,7 @@ class ExpenseList extends StatelessWidget {
fontWeight: 600), fontWeight: 600),
Row( Row(
children: [ children: [
MyText.bodyMedium('${expense.formattedAmount}', MyText.bodyMedium('${expense.formattedAmount}',
fontWeight: 600), fontWeight: 600),
if (expense.status.name.toLowerCase() == 'draft') ...[ if (expense.status.name.toLowerCase() == 'draft') ...[
const SizedBox(width: 8), const SizedBox(width: 8),

View File

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

@ -45,6 +45,330 @@ class SkeletonLoaders {
); );
} }
// 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(
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 // Employee List - Card Style
static Widget employeeListSkeletonLoader() { static Widget employeeListSkeletonLoader() {
return Column( return Column(

View File

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

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

@ -7,19 +7,28 @@ import 'package:marco/view/my_app.dart';
import 'package:marco/helpers/theme/app_notifier.dart'; import 'package:marco/helpers/theme/app_notifier.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/view/layouts/offline_screen.dart'; import 'package:marco/view/layouts/offline_screen.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Initialize logging system
await initLogging(); await initLogging();
logSafe("App starting..."); 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 { try {
await initializeApp(); await initializeApp();
logSafe("App initialized successfully."); logSafe("App initialized successfully.");
runApp( runApp(
ChangeNotifierProvider<AppNotifier>( ChangeNotifierProvider(
create: (_) => AppNotifier(), create: (_) => AppNotifier(),
child: const MainWrapper(), child: const MainWrapper(),
), ),
@ -31,24 +40,21 @@ Future<void> main() async {
error: e, error: e,
stackTrace: stacktrace, stackTrace: stacktrace,
); );
runApp(_buildErrorApp());
}
}
runApp( Widget _buildErrorApp() => const MaterialApp(
const MaterialApp( home: Scaffold(
home: Scaffold( body: Center(
body: Center( child: Text(
child: Text( "Failed to initialize the app.",
"Failed to initialize the app.", style: TextStyle(color: Colors.red),
style: TextStyle(color: Colors.red),
),
), ),
), ),
), ),
); );
}
}
/// This widget listens to connectivity changes and switches between
/// `MyApp` and `OfflineScreen` automatically.
class MainWrapper extends StatefulWidget { class MainWrapper extends StatefulWidget {
const MainWrapper({super.key}); const MainWrapper({super.key});
@ -57,7 +63,6 @@ class MainWrapper extends StatefulWidget {
} }
class _MainWrapperState extends State<MainWrapper> { class _MainWrapperState extends State<MainWrapper> {
// Use a List to store connectivity status as the API now returns a list
List<ConnectivityResult> _connectivityStatus = [ConnectivityResult.none]; List<ConnectivityResult> _connectivityStatus = [ConnectivityResult.none];
final Connectivity _connectivity = Connectivity(); final Connectivity _connectivity = Connectivity();
@ -65,38 +70,23 @@ class _MainWrapperState extends State<MainWrapper> {
void initState() { void initState() {
super.initState(); super.initState();
_initializeConnectivity(); _initializeConnectivity();
// Listen for changes, the callback now provides a List<ConnectivityResult> _connectivity.onConnectivityChanged.listen((results) {
_connectivity.onConnectivityChanged setState(() => _connectivityStatus = results);
.listen((List<ConnectivityResult> results) {
setState(() {
_connectivityStatus = results;
});
}); });
} }
Future<void> _initializeConnectivity() async { Future<void> _initializeConnectivity() async {
// checkConnectivity() now returns a List<ConnectivityResult>
final result = await _connectivity.checkConnectivity(); final result = await _connectivity.checkConnectivity();
setState(() { setState(() => _connectivityStatus = result);
_connectivityStatus = result;
});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Check if any of the connectivity results indicate no internet
final bool isOffline = final bool isOffline =
_connectivityStatus.contains(ConnectivityResult.none); _connectivityStatus.contains(ConnectivityResult.none);
return isOffline
// Show OfflineScreen if no internet ? const MaterialApp(
if (isOffline) { debugShowCheckedModeBanner: false, home: OfflineScreen())
return const MaterialApp( : const MyApp();
debugShowCheckedModeBanner: false,
home: OfflineScreen(),
);
}
// Show main app if online
return 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

@ -3,7 +3,7 @@ import 'package:get/get.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/my_snackbar.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/helpers/utils/attendance_actions.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
@ -66,7 +66,9 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
type: SnackbarType.warning, type: SnackbarType.warning,
); );
return null; return null;
} else if (selected.isAfter(now)) { }
if (selected.isAfter(now)) {
showAppSnackbar( showAppSnackbar(
title: "Invalid Time", title: "Invalid Time",
message: "Future time is not allowed.", message: "Future time is not allowed.",
@ -104,15 +106,18 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
action = 0; action = 0;
actionText = ButtonActions.checkIn; actionText = ButtonActions.checkIn;
break; break;
case 1:
final isOld = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2);
final isOldCheckout = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2);
if (widget.employee.checkOut == null && isOld) { case 1:
final isOldCheckIn =
AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2);
final isOldCheckOut =
AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2);
if (widget.employee.checkOut == null && isOldCheckIn) {
action = 2; action = 2;
actionText = ButtonActions.requestRegularize; actionText = ButtonActions.requestRegularize;
imageCapture = false; imageCapture = false;
} else if (widget.employee.checkOut != null && isOldCheckout) { } else if (widget.employee.checkOut != null && isOldCheckOut) {
action = 2; action = 2;
actionText = ButtonActions.requestRegularize; actionText = ButtonActions.requestRegularize;
} else { } else {
@ -120,10 +125,12 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
actionText = ButtonActions.checkOut; actionText = ButtonActions.checkOut;
} }
break; break;
case 2: case 2:
action = 2; action = 2;
actionText = ButtonActions.requestRegularize; actionText = ButtonActions.requestRegularize;
break; break;
default: default:
action = 0; action = 0;
actionText = "Unknown Action"; actionText = "Unknown Action";
@ -148,25 +155,28 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
} }
} }
final comment = await _showCommentBottomSheet(context, actionText); final comment = await _showCommentBottomSheet(
context,
actionText,
selectedTime: selectedTime,
checkInDate: widget.employee.checkIn,
);
if (comment == null || comment.isEmpty) { if (comment == null || comment.isEmpty) {
controller.uploadingStates[uniqueLogKey]?.value = false; controller.uploadingStates[uniqueLogKey]?.value = false;
return; return;
} }
bool success = false;
String? markTime; String? markTime;
if (actionText == ButtonActions.requestRegularize) { if (actionText == ButtonActions.requestRegularize) {
selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!); selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!);
if (selectedTime != null) { markTime = selectedTime != null
markTime = DateFormat("hh:mm a").format(selectedTime); ? DateFormat("hh:mm a").format(selectedTime)
} : null;
} else if (selectedTime != null) { } else if (selectedTime != null) {
markTime = DateFormat("hh:mm a").format(selectedTime); markTime = DateFormat("hh:mm a").format(selectedTime);
} }
success = await controller.captureAndUploadAttendance( final success = await controller.captureAndUploadAttendance(
widget.employee.id, widget.employee.id,
widget.employee.employeeId, widget.employee.employeeId,
selectedProjectId, selectedProjectId,
@ -187,8 +197,8 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
controller.uploadingStates[uniqueLogKey]?.value = false; controller.uploadingStates[uniqueLogKey]?.value = false;
if (success) { if (success) {
controller.fetchEmployeesByProject(selectedProjectId); await controller.fetchTodaysAttendance(selectedProjectId);
controller.fetchAttendanceLogs(selectedProjectId); await controller.fetchAttendanceLogs(selectedProjectId);
await controller.fetchRegularizationLogs(selectedProjectId); await controller.fetchRegularizationLogs(selectedProjectId);
await controller.fetchProjectData(selectedProjectId); await controller.fetchProjectData(selectedProjectId);
controller.update(); controller.update();
@ -199,13 +209,17 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
final controller = widget.attendanceController; final controller = widget.attendanceController;
final isUploading =
final isUploading = controller.uploadingStates[uniqueLogKey]?.value ?? false; controller.uploadingStates[uniqueLogKey]?.value ?? false;
final emp = widget.employee; final emp = widget.employee;
final isYesterday = AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut); final isYesterday =
final isTodayApproved = AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn); AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut);
final isApprovedButNotToday = AttendanceButtonHelper.isApprovedButNotToday(emp.activity, isTodayApproved); final isTodayApproved =
AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn);
final isApprovedButNotToday =
AttendanceButtonHelper.isApprovedButNotToday(
emp.activity, isTodayApproved);
final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled( final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled(
isUploading: isUploading, isUploading: isUploading,
@ -266,12 +280,12 @@ class AttendanceActionButtonUI extends StatelessWidget {
textStyle: const TextStyle(fontSize: 12), textStyle: const TextStyle(fontSize: 12),
), ),
child: isUploading child: isUploading
? const SizedBox( ? Container(
width: 16, width: 60,
height: 16, height: 14,
child: CircularProgressIndicator( decoration: BoxDecoration(
strokeWidth: 2, color: Colors.white.withOpacity(0.5),
valueColor: AlwaysStoppedAnimation<Color>(Colors.white), borderRadius: BorderRadius.circular(4),
), ),
) )
: Row( : Row(
@ -282,8 +296,10 @@ class AttendanceActionButtonUI extends StatelessWidget {
if (buttonText.toLowerCase() == 'rejected') if (buttonText.toLowerCase() == 'rejected')
const Icon(Icons.close, size: 16, color: Colors.red), const Icon(Icons.close, size: 16, color: Colors.red),
if (buttonText.toLowerCase() == 'requested') if (buttonText.toLowerCase() == 'requested')
const Icon(Icons.hourglass_top, size: 16, color: Colors.orange), const Icon(Icons.hourglass_top,
if (['approved', 'rejected', 'requested'].contains(buttonText.toLowerCase())) size: 16, color: Colors.orange),
if (['approved', 'rejected', 'requested']
.contains(buttonText.toLowerCase()))
const SizedBox(width: 4), const SizedBox(width: 4),
Flexible( Flexible(
child: Text( child: Text(
@ -299,10 +315,22 @@ class AttendanceActionButtonUI extends StatelessWidget {
} }
} }
Future<String?> _showCommentBottomSheet(BuildContext context, String actionText) async { Future<String?> _showCommentBottomSheet(
BuildContext context,
String actionText, {
DateTime? selectedTime,
DateTime? checkInDate,
}) async {
final commentController = TextEditingController(); final commentController = TextEditingController();
String? errorText; 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>( return showModalBottomSheet<String>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@ -323,35 +351,31 @@ Future<String?> _showCommentBottomSheet(BuildContext context, String actionText)
} }
return Padding( return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom),
child: BaseBottomSheet( child: BaseBottomSheet(
title: 'Add Comment for ${capitalizeFirstLetter(actionText)}', title: sheetTitle, // 👈 now showing full sentence as title
onCancel: () => Navigator.of(context).pop(), onCancel: () => Navigator.of(context).pop(),
onSubmit: submit, onSubmit: submit,
isSubmitting: false, isSubmitting: false,
submitText: 'Submit', submitText: 'Submit',
child: Column( child: TextField(
mainAxisSize: MainAxisSize.min, controller: commentController,
children: [ maxLines: 4,
TextField( decoration: InputDecoration(
controller: commentController, hintText: 'Type your comment here...',
maxLines: 4, border: OutlineInputBorder(
decoration: InputDecoration( borderRadius: BorderRadius.circular(8),
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);
}
},
), ),
], filled: true,
fillColor: Colors.grey.shade100,
errorText: errorText,
),
onChanged: (_) {
if (errorText != null) {
setModalState(() => errorText = null);
}
},
), ),
), ),
); );

View File

@ -1,10 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; import 'package:marco/controller/attendance/attendance_screen_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.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 { class AttendanceFilterBottomSheet extends StatefulWidget {
final AttendanceController controller; final AttendanceController controller;
@ -36,14 +37,79 @@ class _AttendanceFilterBottomSheetState
String getLabelText() { String getLabelText() {
final startDate = widget.controller.startDateAttendance; final startDate = widget.controller.startDateAttendance;
final endDate = widget.controller.endDateAttendance; final endDate = widget.controller.endDateAttendance;
if (startDate != null && endDate != null) { if (startDate != null && endDate != null) {
final start = DateFormat('dd/MM/yyyy').format(startDate); final start = DateTimeUtils.formatDate(startDate, 'dd MMM yyyy');
final end = DateFormat('dd/MM/yyyy').format(endDate); final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy');
return "$start - $end"; return "$start - $end";
} }
return "Date Range"; 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() { List<Widget> buildMainFilters() {
final hasRegularizationPermission = widget.permissionController final hasRegularizationPermission = widget.permissionController
.hasPermission(Permissions.regularizeAttendance); .hasPermission(Permissions.regularizeAttendance);
@ -61,7 +127,7 @@ class _AttendanceFilterBottomSheetState
final List<Widget> widgets = [ final List<Widget> widgets = [
Padding( Padding(
padding: EdgeInsets.only(bottom: 4), padding: const EdgeInsets.only(bottom: 4),
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: MyText.titleSmall("View", fontWeight: 600), child: MyText.titleSmall("View", fontWeight: 600),
@ -82,11 +148,66 @@ class _AttendanceFilterBottomSheetState
}), }),
]; ];
// 🔹 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') { if (tempSelectedTab == 'attendanceLogs') {
widgets.addAll([ widgets.addAll([
const Divider(), const Divider(),
Padding( Padding(
padding: EdgeInsets.only(top: 12, bottom: 4), padding: const EdgeInsets.only(top: 12, bottom: 4),
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: MyText.titleSmall("Date Range", fontWeight: 600), child: MyText.titleSmall("Date Range", fontWeight: 600),
@ -99,7 +220,7 @@ class _AttendanceFilterBottomSheetState
context, context,
widget.controller, widget.controller,
); );
setState(() {}); // rebuild UI after date range is updated setState(() {});
}, },
child: Ink( child: Ink(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -136,9 +257,11 @@ class _AttendanceFilterBottomSheetState
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: BaseBottomSheet( child: BaseBottomSheet(
title: "Attendance Filter", title: "Attendance Filter",
submitText: "Apply",
onCancel: () => Navigator.pop(context), onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context, { onSubmit: () => Navigator.pop(context, {
'selectedTab': tempSelectedTab, 'selectedTab': tempSelectedTab,
'selectedOrganization': widget.controller.selectedOrganization?.id,
}), }),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -1,18 +1,25 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:marco/helpers/widgets/my_text.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/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 employee;
final dynamic attendanceController; final dynamic attendanceController;
const AttendanceLogViewButton({ const AttendanceLogViewButton({
Key? key, Key? key,
required this.employee, required this.employee,
required this.attendanceController, required this.attendanceController,
}) : super(key: key); }) : super(key: key);
@override
State<AttendanceLogViewButton> createState() =>
_AttendanceLogViewButtonState();
}
class _AttendanceLogViewButtonState extends State<AttendanceLogViewButton> {
Future<void> _openGoogleMaps( Future<void> _openGoogleMaps(
BuildContext context, double lat, double lon) async { BuildContext context, double lat, double lon) async {
final url = 'https://www.google.com/maps/search/?api=1&query=$lat,$lon'; final url = 'https://www.google.com/maps/search/?api=1&query=$lat,$lon';
@ -49,7 +56,8 @@ class AttendanceLogViewButton extends StatelessWidget {
} }
void _showLogsBottomSheet(BuildContext context) async { void _showLogsBottomSheet(BuildContext context) async {
await attendanceController.fetchLogsView(employee.id.toString()); await widget.attendanceController
.fetchLogsView(widget.employee.id.toString());
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@ -58,157 +66,238 @@ class AttendanceLogViewButton extends StatelessWidget {
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (context) => BaseBottomSheet( builder: (context) {
title: "Attendance Log", Map<int, bool> expandedDescription = {};
onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context), return BaseBottomSheet(
showButtons: false, title: "Attendance Log",
child: attendanceController.attendenceLogsView.isEmpty onCancel: () => Navigator.pop(context),
? Padding( onSubmit: () => Navigator.pop(context),
padding: const EdgeInsets.symmetric(vertical: 24.0), showButtons: false,
child: Column( child: widget.attendanceController.attendenceLogsView.isEmpty
children: const [ ? Padding(
Icon(Icons.info_outline, size: 40, color: Colors.grey), padding: const EdgeInsets.symmetric(vertical: 24.0),
SizedBox(height: 8), child: Column(
Text("No attendance logs available."), children: [
], Icon(Icons.info_outline, size: 40, color: Colors.grey),
), SizedBox(height: 8),
) MyText.bodySmall("No attendance logs available."),
: ListView.separated( ],
shrinkWrap: true, ),
physics: const NeverScrollableScrollPhysics(), )
itemCount: attendanceController.attendenceLogsView.length, : StatefulBuilder(
separatorBuilder: (_, __) => const SizedBox(height: 16), builder: (context, setStateSB) {
itemBuilder: (_, index) { return ListView.separated(
final log = attendanceController.attendenceLogsView[index]; shrinkWrap: true,
return Container( physics: const NeverScrollableScrollPhysics(),
decoration: BoxDecoration( itemCount:
color: Theme.of(context).colorScheme.surfaceVariant, widget.attendanceController.attendenceLogsView.length,
borderRadius: BorderRadius.circular(12), separatorBuilder: (_, __) => const SizedBox(height: 16),
boxShadow: [ itemBuilder: (_, index) {
BoxShadow( final log = widget
color: Colors.black.withOpacity(0.05), .attendanceController.attendenceLogsView[index];
blurRadius: 6,
offset: const Offset(0, 2), return Container(
) decoration: BoxDecoration(
], color: Theme.of(context).colorScheme.surfaceVariant,
), borderRadius: BorderRadius.circular(12),
padding: const EdgeInsets.all(8), boxShadow: [
child: Column( BoxShadow(
crossAxisAlignment: CrossAxisAlignment.start, color: Colors.black.withOpacity(0.05),
children: [ blurRadius: 6,
Row( offset: const Offset(0, 2),
crossAxisAlignment: CrossAxisAlignment.center, )
children: [ ],
Expanded( ),
flex: 3, padding: const EdgeInsets.all(12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: Icon + Date + Time
Row(
children: [ children: [
Row( _getLogIcon(log),
children: [ const SizedBox(width: 12),
_getLogIcon(log), MyText.bodyLarge(
const SizedBox(width: 10), (log.formattedDate != null &&
Column( log.formattedDate!.isNotEmpty)
crossAxisAlignment: ? DateTimeUtils.convertUtcToLocal(
CrossAxisAlignment.start, log.formattedDate!,
children: [ format: 'd MMM yyyy',
MyText.bodyLarge( )
log.formattedDate ?? '-', : '-',
fontWeight: 600, fontWeight: 600,
),
MyText.bodySmall(
"Time: ${log.formattedTime ?? '-'}",
color: Colors.grey[700],
),
],
),
],
), ),
const SizedBox(height: 12), const SizedBox(width: 12),
Row( MyText.bodySmall(
crossAxisAlignment: log.formattedTime != null
CrossAxisAlignment.start, ? "Time: ${log.formattedTime}"
children: [ : "",
if (log.latitude != null && color: Colors.grey[700],
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),
),
),
Expanded(
child: MyText.bodyMedium(
log.comment?.isNotEmpty == true
? log.comment
: "No description provided",
fontWeight: 500,
),
),
],
), ),
], ],
), ),
), const SizedBox(height: 12),
const SizedBox(width: 16), const Divider(height: 1, color: Colors.grey),
if (log.thumbPreSignedUrl != null) // Middle Row: Image + Text (Done by, Description & Location)
GestureDetector( Row(
onTap: () { crossAxisAlignment: CrossAxisAlignment.start,
if (log.preSignedUrl != null) { children: [
_showImageDialog( // Image Column
context, log.preSignedUrl!); if (log.thumbPreSignedUrl != null)
} GestureDetector(
}, onTap: () {
child: ClipRRect( if (log.preSignedUrl != null) {
borderRadius: BorderRadius.circular(8), _showImageDialog(
child: Image.network( context, log.preSignedUrl!);
log.thumbPreSignedUrl!, }
height: 60, },
width: 60, child: ClipRRect(
fit: BoxFit.cover, borderRadius: BorderRadius.circular(8),
errorBuilder: (context, error, stackTrace) { child: Image.network(
return const Icon(Icons.broken_image, log.thumbPreSignedUrl!,
size: 20, color: Colors.grey); height: 60,
}, width: 60,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const Icon(Icons.broken_image,
size: 40, color: Colors.grey),
),
),
),
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)
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,
),
],
),
),
],
),
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,
),
],
),
), ),
), ],
) ),
else ],
const Icon(Icons.broken_image, ),
size: 20, color: Colors.grey), );
], },
), );
], },
), ),
); );
}, },
),
),
); );
} }
@ -219,16 +308,16 @@ class AttendanceLogViewButton extends StatelessWidget {
child: ElevatedButton( child: ElevatedButton(
onPressed: () => _showLogsBottomSheet(context), onPressed: () => _showLogsBottomSheet(context),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AttendanceActionColors.colors[ButtonActions.checkIn], backgroundColor: Colors.indigo,
textStyle: const TextStyle(fontSize: 12), textStyle: const TextStyle(fontSize: 12),
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
), ),
child: const FittedBox( child: FittedBox(
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
child: Text( child: MyText.bodySmall(
"View", "View",
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: Colors.white), color: Colors.white,
), ),
), ),
), ),
@ -249,7 +338,7 @@ class AttendanceLogViewButton extends StatelessWidget {
final today = DateTime(now.year, now.month, now.day); final today = DateTime(now.year, now.month, now.day);
final logDay = DateTime(logDate.year, logDate.month, logDate.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); 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/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
enum ButtonActions { approve, reject } enum ButtonActions { approve, reject }
class RegularizeActionButton extends StatefulWidget { class RegularizeActionButton extends StatefulWidget {
final dynamic final dynamic attendanceController;
attendanceController; final dynamic log;
final dynamic log;
final String uniqueLogKey; final String uniqueLogKey;
final ButtonActions action; final ButtonActions action;
@ -53,57 +53,60 @@ class _RegularizeActionButtonState extends State<RegularizeActionButton> {
Colors.grey; Colors.grey;
} }
Future<void> _handlePress() async { Future<void> _handlePress() async {
final projectController = Get.find<ProjectController>(); final projectController = Get.find<ProjectController>();
final selectedProjectId = projectController.selectedProject?.id; final selectedProjectId = projectController.selectedProject?.id;
if (selectedProjectId == null) { if (selectedProjectId == null) {
showAppSnackbar( showAppSnackbar(
title: 'Warning', title: 'Warning',
message: 'Please select a project first', message: 'Please select a project first',
type: SnackbarType.warning, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final buttonText = _buttonTexts[widget.action]!; final buttonText = _buttonTexts[widget.action]!;
@ -116,17 +119,19 @@ class _RegularizeActionButtonState extends State<RegularizeActionButton> {
onPressed: isUploading ? null : _handlePress, onPressed: isUploading ? null : _handlePress,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
foregroundColor: foregroundColor: Colors.white,
Colors.white, // Ensures visibility on all backgrounds
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 6), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 6),
minimumSize: const Size(60, 20), minimumSize: const Size(60, 20),
textStyle: const TextStyle(fontSize: 12), textStyle: const TextStyle(fontSize: 12),
), ),
child: isUploading child: isUploading
? const SizedBox( ? Container(
width: 16, width: 60,
height: 16, height: 14,
child: CircularProgressIndicator(strokeWidth: 2), decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
) )
: FittedBox( : FittedBox(
fit: BoxFit.scaleDown, 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,368 +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/controller/project_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.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();
final ScrollController _employeeListScrollController = ScrollController();
String? selectedProjectId;
@override
void initState() {
super.initState();
selectedProjectId = projectController.selectedProjectId.value;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (selectedProjectId != null) {
controller.fetchEmployeesByProject(selectedProjectId!);
}
});
}
@override
void dispose() {
_employeeListScrollController.dispose();
targetController.dispose();
descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Obx(() => BaseBottomSheet(
title: "Assign Task",
child: _buildAssignTaskForm(),
onCancel: () => Get.back(),
onSubmit: _onAssignTaskPressed,
isSubmitting: controller.isAssigningTask.value,
));
}
Widget _buildAssignTaskForm() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_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: _onRoleMenuPressed,
child: Row(
children: [
MyText.titleMedium("Select Team :", fontWeight: 600),
const SizedBox(width: 4),
const Icon(Icons.tune, color: Color.fromARGB(255, 95, 132, 255)),
],
),
),
MySpacing.height(8),
Container(
constraints: const BoxConstraints(maxHeight: 150),
child: _buildEmployeeList(),
),
MySpacing.height(8),
_buildSelectedEmployees(),
_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",
),
],
);
}
void _onRoleMenuPressed() {
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);
}
});
}
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,
child: ListView.builder(
controller: _employeeListScrollController,
shrinkWrap: true,
itemCount: filteredEmployees.length,
itemBuilder: (context, index) {
final employee = filteredEmployees[index];
final rxBool = controller.uploadingStates[employee.id];
return Obx(() => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Checkbox(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
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: const TextStyle(fontSize: 14))),
],
),
));
},
),
);
});
}
Widget _buildSelectedEmployees() {
return 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(),
),
);
});
}
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: const InputDecoration(
hintText: '',
border: 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,83 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/dashboard/daily_task_controller.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class DailyProgressReportFilter extends StatelessWidget {
final DailyTaskController controller;
final PermissionController permissionController;
const DailyProgressReportFilter({
super.key,
required this.controller,
required this.permissionController,
});
String getLabelText() {
final startDate = controller.startDateTask;
final endDate = controller.endDateTask;
if (startDate != null && endDate != null) {
final start = DateFormat('dd MM yyyy').format(startDate);
final end = DateFormat('dd MM yyyy').format(endDate);
return "$start - $end";
}
return "Select Date Range";
}
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
title: "Filter Tasks",
onCancel: () => Navigator.pop(context),
onSubmit: () {
Navigator.pop(context, {
'startDate': controller.startDateTask,
'endDate': controller.endDateTask,
});
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall("Select Date Range", fontWeight: 600),
const SizedBox(height: 8),
InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => controller.selectDateRangeForTaskData(
context,
controller,
),
child: Ink(
decoration: BoxDecoration(
color: Colors.grey.shade100,
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.blue.shade600),
const SizedBox(width: 12),
Expanded(
child: Text(
getLabelText(),
style: const TextStyle(
fontSize: 16,
color: Colors.black87,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,407 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/controller/tenant/organization_selection_controller.dart';
import 'package:marco/controller/tenant/service_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/tenant/organization_selector.dart';
import 'package:marco/helpers/widgets/tenant/service_selector.dart';
import 'package:marco/model/attendance/organization_per_project_list_model.dart';
import 'package:marco/model/tenant/tenant_services_model.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 DailyTaskPlanningController controller = Get.find();
final ProjectController projectController = Get.find();
final OrganizationController orgController = Get.put(OrganizationController());
final ServiceController serviceController = Get.put(ServiceController());
final TextEditingController targetController = TextEditingController();
final TextEditingController descriptionController = TextEditingController();
final ScrollController _employeeListScrollController = ScrollController();
String? selectedProjectId;
Organization? selectedOrganization;
Service? selectedService;
@override
void initState() {
super.initState();
selectedProjectId = projectController.selectedProjectId.value;
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (selectedProjectId != null) {
await orgController.fetchOrganizations(selectedProjectId!);
_resetSelections();
await _fetchEmployeesAndTasks();
}
});
}
void _resetSelections() {
controller.selectedEmployees.clear();
controller.uploadingStates.forEach((key, value) => value.value = false);
}
Future<void> _fetchEmployeesAndTasks() async {
await controller.fetchEmployeesByProjectService(
projectId: selectedProjectId!,
serviceId: selectedService?.id,
organizationId: selectedOrganization?.id,
);
await controller.fetchTaskData(selectedProjectId, serviceId: selectedService?.id);
}
@override
void dispose() {
_employeeListScrollController.dispose();
targetController.dispose();
descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Obx(() => BaseBottomSheet(
title: "Assign Task",
child: _buildAssignTaskForm(),
onCancel: () => Get.back(),
onSubmit: _onAssignTaskPressed,
isSubmitting: controller.isAssigningTask.value,
));
}
Widget _buildAssignTaskForm() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Organization Selector
SizedBox(
height: 50,
child: OrganizationSelector(
controller: orgController,
onSelectionChanged: (org) async {
setState(() => selectedOrganization = org);
_resetSelections();
if (selectedProjectId != null) await _fetchEmployeesAndTasks();
},
),
),
MySpacing.height(12),
// Service Selector
SizedBox(
height: 50,
child: ServiceSelector(
controller: serviceController,
onSelectionChanged: (service) async {
setState(() => selectedService = service);
_resetSelections();
if (selectedProjectId != null) await _fetchEmployeesAndTasks();
},
),
),
MySpacing.height(16),
// Work Location Info
_infoRow(
Icons.location_on,
"Work Location",
"${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}",
),
const Divider(),
// Pending Task Info
_infoRow(Icons.pending_actions, "Pending Task", "${widget.pendingTask}"),
const Divider(),
// Role Selector
GestureDetector(
onTap: _onRoleMenuPressed,
child: Row(
children: [
MyText.titleMedium("Select Team :", fontWeight: 600),
const SizedBox(width: 4),
const Icon(Icons.tune, color: Color.fromARGB(255, 95, 132, 255)),
],
),
),
MySpacing.height(8),
// Employee List
Container(
constraints: const BoxConstraints(maxHeight: 180),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(6),
),
child: _buildEmployeeList(),
),
MySpacing.height(8),
// Selected Employees Chips
_buildSelectedEmployees(),
MySpacing.height(8),
// Target Input
_buildTextField(
icon: Icons.track_changes,
label: "Target for Today :",
controller: targetController,
hintText: "Enter target",
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validatorType: "target",
),
MySpacing.height(16),
// Description Input
_buildTextField(
icon: Icons.description,
label: "Description :",
controller: descriptionController,
hintText: "Enter task description",
maxLines: 3,
validatorType: "description",
),
],
);
}
void _onRoleMenuPressed() {
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);
});
}
Widget _buildEmployeeList() {
return Obx(() {
if (controller.isFetchingEmployees.value) {
return Center(child: CircularProgressIndicator());
}
final filteredEmployees = controller.selectedRoleId.value == null
? controller.employees
: controller.employees
.where((e) => e.jobRoleID.toString() == controller.selectedRoleId.value)
.toList();
if (filteredEmployees.isEmpty) {
return Center(child: Text("No employees available for selected role."));
}
return Scrollbar(
controller: _employeeListScrollController,
thumbVisibility: true,
child: ListView.builder(
controller: _employeeListScrollController,
itemCount: filteredEmployees.length,
itemBuilder: (context, index) {
final employee = filteredEmployees[index];
final rxBool = controller.uploadingStates[employee.id];
return Obx(() => ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
leading: Checkbox(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
value: rxBool?.value ?? false,
onChanged: (selected) {
if (rxBool != null) {
rxBool.value = selected ?? false;
controller.updateSelectedEmployees();
}
},
fillColor: MaterialStateProperty.resolveWith((states) =>
states.contains(MaterialState.selected)
? const Color.fromARGB(255, 95, 132, 255)
: Colors.transparent),
checkColor: Colors.white,
side: const BorderSide(color: Colors.black),
),
title: Text(employee.name, style: const TextStyle(fontSize: 14)),
visualDensity: VisualDensity.compact,
));
},
),
);
});
}
Widget _buildSelectedEmployees() {
return Obx(() {
if (controller.selectedEmployees.isEmpty) return Container();
return 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(),
);
});
}
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: OutlineInputBorder(borderRadius: BorderRadius.circular(6)),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
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 = double.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 exceed 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.toInt(),
description: description,
taskTeam: selectedTeam,
assignmentDate: widget.assignmentDate,
organizationId: selectedOrganization?.id,
serviceId: selectedService?.id,
);
}
}

View File

@ -4,7 +4,7 @@ import 'package:intl/intl.dart';
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
// --- Assumed Imports (ensure these paths are correct in your project) --- // --- Assumed Imports (ensure these paths are correct in your project) ---
import 'package:marco/controller/task_planing/report_task_controller.dart'; import 'package:marco/controller/task_planning/report_task_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
@ -13,7 +13,7 @@ import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/widgets/my_team_model_sheet.dart'; import 'package:marco/helpers/widgets/my_team_model_sheet.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:marco/model/dailyTaskPlaning/create_task_botom_sheet.dart'; import 'package:marco/model/dailyTaskPlanning/create_task_botom_sheet.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
// --- Form Field Keys (Unchanged) --- // --- Form Field Keys (Unchanged) ---
@ -249,7 +249,7 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSectionHeader("Add Comment", Icons.comment_outlined), _buildSectionHeader("Add Note", Icons.comment_outlined),
MySpacing.height(8), MySpacing.height(8),
TextFormField( TextFormField(
validator: validator:

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/task_planing/add_task_controller.dart'; import 'package:marco/controller/task_planning/add_task_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';

View File

@ -0,0 +1,290 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/task_planning/daily_task_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class DailyTaskFilterBottomSheet extends StatelessWidget {
final DailyTaskController controller;
const DailyTaskFilterBottomSheet({super.key, required this.controller});
@override
Widget build(BuildContext context) {
final filterData = controller.taskFilterData;
if (filterData == null) return const SizedBox.shrink();
final hasFilters = [
filterData.buildings,
filterData.floors,
filterData.activities,
filterData.services,
].any((list) => list.isNotEmpty);
return BaseBottomSheet(
title: "Filter Tasks",
submitText: "Apply",
showButtons: hasFilters,
onCancel: () => Get.back(),
onSubmit: () {
if (controller.selectedProjectId != null) {
controller.fetchTaskData(
controller.selectedProjectId!,
);
}
Get.back();
},
child: SingleChildScrollView(
child: hasFilters
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
controller.clearTaskFilters();
},
child: MyText(
"Reset Filter",
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.w600,
),
),
),
),
MySpacing.height(8),
_multiSelectField(
label: "Buildings",
items: filterData.buildings,
fallback: "Select Buildings",
selectedValues: controller.selectedBuildings,
),
_multiSelectField(
label: "Floors",
items: filterData.floors,
fallback: "Select Floors",
selectedValues: controller.selectedFloors,
),
_multiSelectField(
label: "Activities",
items: filterData.activities,
fallback: "Select Activities",
selectedValues: controller.selectedActivities,
),
_multiSelectField(
label: "Services",
items: filterData.services,
fallback: "Select Services",
selectedValues: controller.selectedServices,
),
MySpacing.height(8),
_dateRangeSelector(context),
],
)
: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: MyText(
"No filters available",
style: const TextStyle(color: Colors.grey),
),
),
),
),
);
}
Widget _multiSelectField({
required String label,
required List<dynamic> items,
required String fallback,
required RxSet<String> selectedValues,
}) {
if (items.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
MySpacing.height(8),
Obx(() {
final selectedNames = items
.where((item) => selectedValues.contains(item.id))
.map((item) => item.name)
.join(", ");
final displayText =
selectedNames.isNotEmpty ? selectedNames : fallback;
return Builder(
builder: (context) {
return GestureDetector(
onTap: () async {
final RenderBox button =
context.findRenderObject() as RenderBox;
final RenderBox overlay = Overlay.of(context)
.context
.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero);
await showMenu(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy + button.size.height,
overlay.size.width - position.dx - button.size.width,
0,
),
items: items.map((item) {
return PopupMenuItem<String>(
enabled: false,
child: StatefulBuilder(
builder: (context, setState) {
final isChecked = selectedValues.contains(item.id);
return CheckboxListTile(
dense: true,
value: isChecked,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: MyText(item.name),
// --- Styles to match Document Filter ---
checkColor: Colors.white,
side: const BorderSide(
color: Colors.black, width: 1.5),
fillColor:
MaterialStateProperty.resolveWith<Color>(
(states) {
if (states.contains(MaterialState.selected)) {
return Colors.indigo;
}
return Colors.white;
},
),
onChanged: (val) {
if (val == true) {
selectedValues.add(item.id);
} else {
selectedValues.remove(item.id);
}
setState(() {});
},
);
},
),
);
}).toList(),
);
},
child: Container(
padding: MySpacing.all(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(
displayText,
style: const TextStyle(color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
);
},
);
}),
MySpacing.height(16),
],
);
}
Widget _dateRangeSelector(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium("Select Date Range"),
MySpacing.height(8),
Row(
children: [
Expanded(
child: _dateButton(
label: controller.startDateTask != null
? "${controller.startDateTask!.day}/${controller.startDateTask!.month}/${controller.startDateTask!.year}"
: "From Date",
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: controller.startDateTask ?? DateTime.now(),
firstDate: DateTime(2022),
lastDate: DateTime.now(),
);
if (picked != null) {
controller.startDateTask = picked;
controller.update(); // rebuild widget
}
},
),
),
MySpacing.width(12),
Expanded(
child: _dateButton(
label: controller.endDateTask != null
? "${controller.endDateTask!.day}/${controller.endDateTask!.month}/${controller.endDateTask!.year}"
: "To Date",
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: controller.endDateTask ?? DateTime.now(),
firstDate: DateTime(2022),
lastDate: DateTime.now(),
);
if (picked != null) {
controller.endDateTask = picked;
controller.update();
}
},
),
),
],
),
MySpacing.height(16),
],
);
}
Widget _dateButton({required String label, required VoidCallback onTap}) {
return GestureDetector(
onTap: onTap,
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(
children: [
const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
const SizedBox(width: 8),
Expanded(
child: MyText(label),
),
],
),
),
);
}
}

View File

@ -0,0 +1,128 @@
class DailyProgressReportFilterResponse {
final bool success;
final String message;
final FilterData? data;
final dynamic errors;
final int statusCode;
final String timestamp;
DailyProgressReportFilterResponse({
required this.success,
required this.message,
this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory DailyProgressReportFilterResponse.fromJson(Map<String, dynamic> json) {
return DailyProgressReportFilterResponse(
success: json['success'],
message: json['message'],
data: json['data'] != null ? FilterData.fromJson(json['data']) : null,
errors: json['errors'],
statusCode: json['statusCode'],
timestamp: json['timestamp'],
);
}
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp,
};
}
class FilterData {
final List<Building> buildings;
final List<Floor> floors;
final List<Activity> activities;
final List<Service> services;
FilterData({
required this.buildings,
required this.floors,
required this.activities,
required this.services,
});
factory FilterData.fromJson(Map<String, dynamic> json) {
return FilterData(
buildings: (json['buildings'] as List)
.map((e) => Building.fromJson(e))
.toList(),
floors:
(json['floors'] as List).map((e) => Floor.fromJson(e)).toList(),
activities:
(json['activities'] as List).map((e) => Activity.fromJson(e)).toList(),
services:
(json['services'] as List).map((e) => Service.fromJson(e)).toList(),
);
}
Map<String, dynamic> toJson() => {
'buildings': buildings.map((e) => e.toJson()).toList(),
'floors': floors.map((e) => e.toJson()).toList(),
'activities': activities.map((e) => e.toJson()).toList(),
'services': services.map((e) => e.toJson()).toList(),
};
}
class Building {
final String id;
final String name;
Building({required this.id, required this.name});
factory Building.fromJson(Map<String, dynamic> json) =>
Building(id: json['id'], name: json['name']);
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}
class Floor {
final String id;
final String name;
final String buildingId;
Floor({required this.id, required this.name, required this.buildingId});
factory Floor.fromJson(Map<String, dynamic> json) => Floor(
id: json['id'],
name: json['name'],
buildingId: json['buildingId'],
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'buildingId': buildingId,
};
}
class Activity {
final String id;
final String name;
Activity({required this.id, required this.name});
factory Activity.fromJson(Map<String, dynamic> json) =>
Activity(id: json['id'], name: json['name']);
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}
class Service {
final String id;
final String name;
Service({required this.id, required this.name});
factory Service.fromJson(Map<String, dynamic> json) =>
Service(id: json['id'], name: json['name']);
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}

View File

@ -16,38 +16,36 @@ class TaskModel {
required this.assignmentDate, required this.assignmentDate,
this.reportedDate, this.reportedDate,
required this.id, required this.id,
required this.workItem, this.workItem,
required this.workItemId, required this.workItemId,
required this.plannedTask, required this.plannedTask,
required this.completedTask, required this.completedTask,
required this.assignedBy, required this.assignedBy,
this.approvedBy, this.approvedBy,
required this.teamMembers, this.teamMembers = const [],
required this.comments, this.comments = const [],
required this.reportedPreSignedUrls, this.reportedPreSignedUrls = const [],
}); });
factory TaskModel.fromJson(Map<String, dynamic> json) { factory TaskModel.fromJson(Map<String, dynamic> json) {
return TaskModel( return TaskModel(
assignmentDate: DateTime.parse(json['assignmentDate']), assignmentDate: DateTime.parse(json['assignmentDate'] ?? DateTime.now().toIso8601String()),
reportedDate: json['reportedDate'] != null reportedDate: json['reportedDate'] != null ? DateTime.tryParse(json['reportedDate']) : null,
? DateTime.tryParse(json['reportedDate']) id: json['id']?.toString() ?? '',
: null, workItem: json['workItem'] != null ? WorkItem.fromJson(json['workItem']) : null,
id: json['id'], workItemId: json['workItemId']?.toString() ?? '',
workItem: plannedTask: (json['plannedTask'] as num?)?.toDouble() ?? 0,
json['workItem'] != null ? WorkItem.fromJson(json['workItem']) : null, completedTask: (json['completedTask'] as num?)?.toDouble() ?? 0,
workItemId: json['workItemId'], assignedBy: AssignedBy.fromJson(json['assignedBy'] ?? {}),
plannedTask: (json['plannedTask'] as num).toDouble(), approvedBy: json['approvedBy'] != null ? AssignedBy.fromJson(json['approvedBy']) : null,
completedTask: (json['completedTask'] as num).toDouble(), teamMembers: (json['teamMembers'] as List<dynamic>?)
assignedBy: AssignedBy.fromJson(json['assignedBy']), ?.map((e) => TeamMember.fromJson(e))
approvedBy: json['approvedBy'] != null .toList() ??
? AssignedBy.fromJson(json['approvedBy']) [],
: null, comments: (json['comments'] as List<dynamic>?)
teamMembers: (json['teamMembers'] as List) ?.map((e) => Comment.fromJson(e))
.map((e) => TeamMember.fromJson(e)) .toList() ??
.toList(), [],
comments:
(json['comments'] as List).map((e) => Comment.fromJson(e)).toList(),
reportedPreSignedUrls: (json['reportedPreSignedUrls'] as List<dynamic>?) reportedPreSignedUrls: (json['reportedPreSignedUrls'] as List<dynamic>?)
?.map((e) => e.toString()) ?.map((e) => e.toString())
.toList() ?? .toList() ??
@ -79,8 +77,7 @@ class WorkItem {
activityMaster: json['activityMaster'] != null activityMaster: json['activityMaster'] != null
? ActivityMaster.fromJson(json['activityMaster']) ? ActivityMaster.fromJson(json['activityMaster'])
: null, : null,
workArea: workArea: json['workArea'] != null ? WorkArea.fromJson(json['workArea']) : null,
json['workArea'] != null ? WorkArea.fromJson(json['workArea']) : null,
plannedWork: (json['plannedWork'] as num?)?.toDouble(), plannedWork: (json['plannedWork'] as num?)?.toDouble(),
completedWork: (json['completedWork'] as num?)?.toDouble(), completedWork: (json['completedWork'] as num?)?.toDouble(),
preSignedUrls: (json['preSignedUrls'] as List<dynamic>?) preSignedUrls: (json['preSignedUrls'] as List<dynamic>?)
@ -92,7 +89,7 @@ class WorkItem {
} }
class ActivityMaster { class ActivityMaster {
final String? id; // Added final String? id;
final String activityName; final String activityName;
ActivityMaster({ ActivityMaster({
@ -103,13 +100,13 @@ class ActivityMaster {
factory ActivityMaster.fromJson(Map<String, dynamic> json) { factory ActivityMaster.fromJson(Map<String, dynamic> json) {
return ActivityMaster( return ActivityMaster(
id: json['id']?.toString(), id: json['id']?.toString(),
activityName: json['activityName'] ?? '', activityName: json['activityName']?.toString() ?? '',
); );
} }
} }
class WorkArea { class WorkArea {
final String? id; // Added final String? id;
final String areaName; final String areaName;
final Floor? floor; final Floor? floor;
@ -122,7 +119,7 @@ class WorkArea {
factory WorkArea.fromJson(Map<String, dynamic> json) { factory WorkArea.fromJson(Map<String, dynamic> json) {
return WorkArea( return WorkArea(
id: json['id']?.toString(), id: json['id']?.toString(),
areaName: json['areaName'] ?? '', areaName: json['areaName']?.toString() ?? '',
floor: json['floor'] != null ? Floor.fromJson(json['floor']) : null, floor: json['floor'] != null ? Floor.fromJson(json['floor']) : null,
); );
} }
@ -136,9 +133,8 @@ class Floor {
factory Floor.fromJson(Map<String, dynamic> json) { factory Floor.fromJson(Map<String, dynamic> json) {
return Floor( return Floor(
floorName: json['floorName'] ?? '', floorName: json['floorName']?.toString() ?? '',
building: building: json['building'] != null ? Building.fromJson(json['building']) : null,
json['building'] != null ? Building.fromJson(json['building']) : null,
); );
} }
} }
@ -149,7 +145,7 @@ class Building {
Building({required this.name}); Building({required this.name});
factory Building.fromJson(Map<String, dynamic> json) { factory Building.fromJson(Map<String, dynamic> json) {
return Building(name: json['name'] ?? ''); return Building(name: json['name']?.toString() ?? '');
} }
} }
@ -167,8 +163,8 @@ class AssignedBy {
factory AssignedBy.fromJson(Map<String, dynamic> json) { factory AssignedBy.fromJson(Map<String, dynamic> json) {
return AssignedBy( return AssignedBy(
id: json['id']?.toString() ?? '', id: json['id']?.toString() ?? '',
firstName: json['firstName'] ?? '', firstName: json['firstName']?.toString() ?? '',
lastName: json['lastName'], lastName: json['lastName']?.toString(),
); );
} }
} }
@ -203,7 +199,7 @@ class Comment {
required this.comment, required this.comment,
required this.commentedBy, required this.commentedBy,
required this.timestamp, required this.timestamp,
required this.preSignedUrls, this.preSignedUrls = const [],
}); });
factory Comment.fromJson(Map<String, dynamic> json) { factory Comment.fromJson(Map<String, dynamic> json) {
@ -212,7 +208,9 @@ class Comment {
commentedBy: json['employee'] != null commentedBy: json['employee'] != null
? TeamMember.fromJson(json['employee']) ? TeamMember.fromJson(json['employee'])
: TeamMember(id: '', firstName: '', lastName: null), : TeamMember(id: '', firstName: '', lastName: null),
timestamp: DateTime.parse(json['commentDate'] ?? ''), timestamp: json['commentDate'] != null
? DateTime.parse(json['commentDate'])
: DateTime.now(),
preSignedUrls: (json['preSignedUrls'] as List<dynamic>?) preSignedUrls: (json['preSignedUrls'] as List<dynamic>?)
?.map((e) => e.toString()) ?.map((e) => e.toString())
.toList() ?? .toList() ??

View File

@ -1,13 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/permission_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/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
class DailyTaskPlaningFilter extends StatelessWidget { class DailyTaskPlanningFilter extends StatelessWidget {
final DailyTaskPlaningController controller; final DailyTaskPlanningController controller;
final PermissionController permissionController; final PermissionController permissionController;
const DailyTaskPlaningFilter({ const DailyTaskPlanningFilter({
super.key, super.key,
required this.controller, required this.controller,
required this.permissionController, required this.permissionController,

View File

@ -129,6 +129,8 @@ class WorkItem {
final WorkCategoryMaster? workCategoryMaster; final WorkCategoryMaster? workCategoryMaster;
final double? plannedWork; final double? plannedWork;
final double? completedWork; final double? completedWork;
final String? description;
final double? todaysAssigned;
final DateTime? taskDate; final DateTime? taskDate;
final String? tenantId; final String? tenantId;
final Tenant? tenant; final Tenant? tenant;
@ -141,8 +143,10 @@ class WorkItem {
this.workArea, this.workArea,
this.activityMaster, this.activityMaster,
this.workCategoryMaster, this.workCategoryMaster,
this.description,
this.plannedWork, this.plannedWork,
this.completedWork, this.completedWork,
this.todaysAssigned,
this.taskDate, this.taskDate,
this.tenantId, this.tenantId,
this.tenant, this.tenant,
@ -171,6 +175,10 @@ class WorkItem {
completedWork: json['completedWork'] != null completedWork: json['completedWork'] != null
? (json['completedWork'] as num).toDouble() ? (json['completedWork'] as num).toDouble()
: null, : null,
todaysAssigned: json['todaysAssigned'] != null
? (json['todaysAssigned'] as num).toDouble()
: null,
description: json['description'] as String?,
taskDate: taskDate:
json['taskDate'] != null ? DateTime.tryParse(json['taskDate']) : null, json['taskDate'] != null ? DateTime.tryParse(json['taskDate']) : null,
tenantId: json['tenantId'] as String?, tenantId: json['tenantId'] as String?,

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/task_planing/report_task_action_controller.dart'; import 'package:marco/controller/task_planning/report_task_action_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
@ -8,8 +8,8 @@ import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/widgets/my_team_model_sheet.dart'; import 'package:marco/helpers/widgets/my_team_model_sheet.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:marco/model/dailyTaskPlaning/create_task_botom_sheet.dart'; import 'package:marco/model/dailyTaskPlanning/create_task_botom_sheet.dart';
import 'package:marco/model/dailyTaskPlaning/report_action_widgets.dart'; import 'package:marco/model/dailyTaskPlanning/report_action_widgets.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class ReportActionBottomSheet extends StatefulWidget { class ReportActionBottomSheet extends StatefulWidget {
@ -147,8 +147,9 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
floatingLabelBehavior: FloatingLabelBehavior.never, floatingLabelBehavior: FloatingLabelBehavior.never,
), ),
), ),
MySpacing.height(10), MySpacing.height(10),
// Reported Images Section
if ((widget.taskData['reportedPreSignedUrls'] as List<dynamic>?) if ((widget.taskData['reportedPreSignedUrls'] as List<dynamic>?)
?.isNotEmpty == ?.isNotEmpty ==
true) true)
@ -157,39 +158,37 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
widget.taskData['reportedPreSignedUrls'] ?? []), widget.taskData['reportedPreSignedUrls'] ?? []),
context: context, context: context,
), ),
MySpacing.height(10), MySpacing.height(10),
// Report Actions Dropdown
MyText.titleSmall("Report Actions", fontWeight: 600), MyText.titleSmall("Report Actions", fontWeight: 600),
MySpacing.height(10), MySpacing.height(10),
Obx(() { Obx(() {
if (controller.isLoadingWorkStatus.value) if (controller.isLoadingWorkStatus.value)
return const CircularProgressIndicator(); return const CircularProgressIndicator();
return PopupMenuButton<String>( return PopupMenuButton<String>(
onSelected: (String value) { onSelected: (value) {
controller.selectedWorkStatusName.value = value; controller.selectedWorkStatusName.value = value;
controller.showAddTaskCheckbox.value = true; controller.showAddTaskCheckbox.value = true;
}, },
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)), borderRadius: BorderRadius.circular(12)),
itemBuilder: (BuildContext context) { itemBuilder: (context) => controller.workStatus.map((status) {
return controller.workStatus.map((status) { return PopupMenuItem<String>(
return PopupMenuItem<String>( value: status.name,
value: status.name, child: Row(
child: Row( children: [
children: [ Radio<String>(
Radio<String>( value: status.name,
value: status.name, groupValue: controller.selectedWorkStatusName.value,
groupValue: controller.selectedWorkStatusName.value, onChanged: (_) => Navigator.pop(context, status.name),
onChanged: (_) => Navigator.pop(context, status.name), ),
), const SizedBox(width: 8),
const SizedBox(width: 8), MyText.bodySmall(status.name),
MyText.bodySmall(status.name), ],
], ),
), );
); }).toList(),
}).toList();
},
child: Container( child: Container(
padding: MySpacing.xy(16, 12), padding: MySpacing.xy(16, 12),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -211,25 +210,39 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
), ),
); );
}), }),
MySpacing.height(10), MySpacing.height(10),
// Add New Task Checkbox
Obx(() { Obx(() {
if (!controller.showAddTaskCheckbox.value) if (!controller.showAddTaskCheckbox.value)
return const SizedBox.shrink(); return const SizedBox.shrink();
return CheckboxListTile( return Theme(
title: MyText.titleSmall("Add new task", fontWeight: 600), data: Theme.of(context).copyWith(
value: controller.isAddTaskChecked.value, checkboxTheme: CheckboxThemeData(
onChanged: (val) => shape: RoundedRectangleBorder(
controller.isAddTaskChecked.value = val ?? false, borderRadius: BorderRadius.circular(4)),
controlAffinity: ListTileControlAffinity.leading, side: const BorderSide(color: Colors.black, width: 2),
contentPadding: EdgeInsets.zero, fillColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected))
return Colors.blueAccent;
return Colors.white;
}),
checkColor: MaterialStateProperty.all(Colors.white),
),
),
child: CheckboxListTile(
title: MyText.titleSmall("Add new task", fontWeight: 600),
value: controller.isAddTaskChecked.value,
onChanged: (val) =>
controller.isAddTaskChecked.value = val ?? false,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
); );
}), }),
MySpacing.height(24), MySpacing.height(24),
// Comment Field // 💬 Comment Field
Row( Row(
children: [ children: [
Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]), Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]),
@ -239,8 +252,8 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
), ),
MySpacing.height(8), MySpacing.height(8),
TextFormField( TextFormField(
validator: controller.basicValidator.getValidation('comment'),
controller: controller.basicValidator.getController('comment'), controller: controller.basicValidator.getController('comment'),
validator: controller.basicValidator.getValidation('comment'),
keyboardType: TextInputType.text, keyboardType: TextInputType.text,
decoration: InputDecoration( decoration: InputDecoration(
hintText: "eg: Work done successfully", hintText: "eg: Work done successfully",
@ -250,10 +263,9 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
floatingLabelBehavior: FloatingLabelBehavior.never, floatingLabelBehavior: FloatingLabelBehavior.never,
), ),
), ),
MySpacing.height(16), MySpacing.height(16),
// 📸 Image Attachments // 📸 Attach Photos
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -278,21 +290,18 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
onCameraTap: () => controller.pickImages(fromCamera: true), onCameraTap: () => controller.pickImages(fromCamera: true),
onUploadTap: () => controller.pickImages(fromCamera: false), onUploadTap: () => controller.pickImages(fromCamera: false),
onRemoveImage: (index) => controller.removeImageAt(index), onRemoveImage: (index) => controller.removeImageAt(index),
onPreviewImage: (index) { onPreviewImage: (index) => showDialog(
showDialog( context: context,
context: context, builder: (_) => ImageViewerDialog(
builder: (_) => ImageViewerDialog( imageSources: images,
imageSources: images, initialIndex: index,
initialIndex: index, ),
), ),
);
},
); );
}), }),
MySpacing.height(12), MySpacing.height(12),
// Submit/Cancel Buttons moved here // Submit/Cancel Buttons
Row( Row(
children: [ children: [
Expanded( Expanded(
@ -328,7 +337,6 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
?.text ?.text
.trim() ?? .trim() ??
''; '';
final shouldShowAddTaskSheet = final shouldShowAddTaskSheet =
controller.isAddTaskChecked.value; controller.isAddTaskChecked.value;
@ -389,10 +397,9 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
), ),
], ],
), ),
MySpacing.height(12), MySpacing.height(12),
// 💬 Previous Comments List (only below submit) // 💬 Previous Comments
if ((widget.taskData['taskComments'] as List<dynamic>?)?.isNotEmpty == if ((widget.taskData['taskComments'] as List<dynamic>?)?.isNotEmpty ==
true) ...[ true) ...[
Row( Row(

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/task_planing/report_task_controller.dart'; import 'package:marco/controller/task_planning/report_task_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';

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