Compare commits
203 Commits
Vaibhav_Is
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 038b33e3b8 | |||
| 83218166ba | |||
| 26611d3650 | |||
| 7fb5a5217a | |||
| 706881d08d | |||
| e8acfe10d9 | |||
| 16e2f5a4f3 | |||
| cd92d4d309 | |||
| 5dc2db0a8b | |||
| bb5fdb27b2 | |||
| acb203848e | |||
| e6238ca5b0 | |||
| d5a8d08e63 | |||
| 041b62ca2f | |||
| d1d48b1a74 | |||
| 7e75431feb | |||
| 45bc492683 | |||
| 26675388dd | |||
| d02211d389 | |||
| 5086b3be98 | |||
| 539e94fc99 | |||
| dbd4a42b7a | |||
| 38ae9e3571 | |||
| 7f924ee533 | |||
| d6587931fa | |||
| 8ad9690d89 | |||
| b286ab854a | |||
| 8576448a32 | |||
| 075167e285 | |||
| fd7c338c05 | |||
| b5d8d41e42 | |||
| 781a8dabaf | |||
| 98612db7b5 | |||
| 7c21324b42 | |||
| 1900e944e5 | |||
| 53fefbba50 | |||
| 9012218a44 | |||
| fb26ba0757 | |||
| aa5ae29284 | |||
| 83d9d0689a | |||
| 85d3dedbef | |||
| 1d9c416f68 | |||
| 7d211e24f8 | |||
| fc081c779e | |||
| 4cb60138c0 | |||
| 68cfdf54d6 | |||
| 83a8abbb87 | |||
| 17c7b9f10d | |||
| efb5564fcb | |||
| 637426aea4 | |||
| 8ed67dcdf1 | |||
| 6863769b8a | |||
| 8d3c900262 | |||
| 9362945d60 | |||
| b5e9c7b6a3 | |||
| 04cbdab277 | |||
| ae7ce851ee | |||
| 8c99ba287f | |||
| bbe7f4a215 | |||
| 85d776b60b | |||
| 4836dd994c | |||
| 544eb4dc79 | |||
| 957bae526f | |||
| a1cd212e74 | |||
| 47666c7897 | |||
| e6f028d129 | |||
| 1fafe77211 | |||
| 25b20fedda | |||
| 7d5d2b5bf4 | |||
| ef1403bec9 | |||
| 3b497fecaf | |||
| 8fb725a5cf | |||
| 2517f2360e | |||
| d2712b8288 | |||
| fd7f108a20 | |||
| 61acbb019b | |||
| be908a5251 | |||
| 5c923bb48b | |||
| bd6f175ca7 | |||
| 229531c5bf | |||
| 20365697a7 | |||
| 0cccdc6b05 | |||
| 6d70afc779 | |||
| a02887845b | |||
| 99bd26942c | |||
| bf84ef4786 | |||
| 4d11a2ccf0 | |||
| e12e5ab13b | |||
| 2133dedfae | |||
| 334023bf1b | |||
| 40a4a77af5 | |||
| c27b226b58 | |||
| a0f3475c5e | |||
| 3aab006bea | |||
| f5d4ab8415 | |||
| d62f8aa9ef | |||
| 80d5fc5f21 | |||
| a154872649 | |||
| 91184b48bb | |||
| c69e0d5221 | |||
| ac6b6e6173 | |||
| 35a0228cbe | |||
| 5977fef261 | |||
| 311002f3ba | |||
| 8783e7a503 | |||
| 8688e84779 | |||
| 911cddf97c | |||
| 1af3ae2aa6 | |||
| 3ba3129b18 | |||
| 98d2dd4c46 | |||
| cc0bb7aafc | |||
| 6187d63ccc | |||
| 83804cde3f | |||
| 1e48c686b2 | |||
| f24bff4fad | |||
| fa767ea201 | |||
| c9882879ff | |||
| a6da579f07 | |||
| 9feb9d1b4b | |||
| 2013447904 | |||
| 2fb3c36ba4 | |||
| 0e177e5a1f | |||
| 7175ade940 | |||
| 10e4a6e514 | |||
| 77dcd9af8e | |||
| 858fe7435d | |||
| 93f9a6e738 | |||
| 092fe21252 | |||
| 1d17a8e109 | |||
| 7ec8b1e7bc | |||
| d205cc2014 | |||
| a5058cd0bc | |||
| 754f919cdc | |||
| b0c9a2c45f | |||
| 3195fdd4a0 | |||
| 06fc8a4c61 | |||
| 63e5caae24 | |||
| aa76ec60cb | |||
| 0401b41b3c | |||
| 0acd619d78 | |||
| f1220cc018 | |||
| 84811635d0 | |||
| 7dbc9138c6 | |||
| f245f9accf | |||
| 0150400092 | |||
| bba44d4d39 | |||
| 7dd47ce460 | |||
| d799093537 | |||
| 9d9afe37b8 | |||
| fe66f35be7 | |||
| 70443d8e24 | |||
| 0f0eb51c15 | |||
| 2518b65cb7 | |||
| 5f66c4c647 | |||
| d0cbfa987d | |||
| 0f14fda83a | |||
| 7ce07c9b47 | |||
| f5eed0a0b9 | |||
| 6d29d444fa | |||
| 797df80890 | |||
| 3427c5bd26 | |||
| 31966f4bc5 | |||
| 29f759ca9d | |||
| adf5e1437e | |||
| 9d49f2a92d | |||
| cef3bd8a1e | |||
| 2b34635a75 | |||
| 154cfdb471 | |||
| d28332b55d | |||
| 98836f8157 | |||
| ddbc1ec1e5 | |||
| 5bc811f91f | |||
| f4b905cd42 | |||
| e5b3616245 | |||
| 9124b815ef | |||
| a83954c5c4 | |||
| 586d18565f | |||
| e0ed35a671 | |||
| debc12bc1b | |||
| d90673523a | |||
| d7b62323d6 | |||
| ff01c05a73 | |||
| a7bb24ee29 | |||
| af83d66390 | |||
| b40d371d43 | |||
| 982c81f849 | |||
| 9e4c0378c6 | |||
| 069fa29aa7 | |||
| 25a1331878 | |||
| 380bdf870e | |||
| 008d35d576 | |||
| 69e64ec789 | |||
| 943c7c7b50 | |||
| 0e1b6e2a8c | |||
| d0f42da30f | |||
| 93cdaab2c2 | |||
| 4a01371fad | |||
| f7352eb3c3 | |||
| ee469f694e | |||
| 6c0e73d870 | |||
| 30318cd294 | |||
| 8c5035d679 | |||
| 60060eaa5e |
@ -3,42 +3,84 @@ plugins {
|
|||||||
id "kotlin-android"
|
id "kotlin-android"
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id "dev.flutter.flutter-gradle-plugin"
|
id "dev.flutter.flutter-gradle-plugin"
|
||||||
|
id("com.google.gms.google-services")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load keystore properties from key.properties file
|
||||||
|
def keystoreProperties = new Properties()
|
||||||
|
def keystorePropertiesFile = rootProject.file('key.properties')
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.marco"
|
// Define the namespace for your Android application
|
||||||
|
namespace = "com.marco.aiot"
|
||||||
|
// Set the compile SDK version based on Flutter's configuration
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
// Set the NDK version based on Flutter's configuration
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
// Configure Java compatibility options
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
// ✅ Enable core library desugaring for Java 8+ APIs
|
||||||
|
coreLibraryDesugaringEnabled true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure Kotlin options for JVM target
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_1_8
|
jvmTarget = JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default configuration for your application
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// Specify your unique Application ID. This identifies your app on Google Play.
|
||||||
applicationId = "com.example.marcostage"
|
applicationId = "com.marco.aiot"
|
||||||
// You can update the following values to match your application needs.
|
// Set minimum and target SDK versions based on Flutter's configuration
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
minSdk = 23
|
||||||
minSdk = flutter.minSdkVersion
|
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
// Set version code and name based on Flutter's configuration (from pubspec.yaml)
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Define signing configurations for different build types
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
// Reference the key alias from key.properties
|
||||||
|
keyAlias keystoreProperties['keyAlias']
|
||||||
|
// Reference the key password from key.properties
|
||||||
|
keyPassword keystoreProperties['keyPassword']
|
||||||
|
// Reference the keystore file path from key.properties
|
||||||
|
storeFile file(keystoreProperties['storeFile'])
|
||||||
|
// Reference the keystore password from key.properties
|
||||||
|
storePassword keystoreProperties['storePassword']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define different build types (e.g., debug, release)
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// Apply the 'release' signing configuration defined above to the release build
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
signingConfig signingConfigs.release
|
||||||
signingConfig = signingConfigs.debug
|
// Enable code minification to reduce app size
|
||||||
|
minifyEnabled true
|
||||||
|
// Enable resource shrinking to remove unused resources
|
||||||
|
shrinkResources true
|
||||||
|
// Other release specific configurations can be added here, e.g., ProGuard rules
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure Flutter specific settings, pointing to the root of your Flutter project
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ Add required dependencies for desugaring
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
29
android/app/google-services.json
Normal file
29
android/app/google-services.json
Normal 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"
|
||||||
|
}
|
||||||
@ -6,5 +6,6 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -1,16 +1,14 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="Marco_Stage"
|
android:label="Marco"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
@ -40,6 +38,9 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||||
|
android:value="high_importance_channel"/>
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package com.example.marco
|
package com.marco.aiot
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
13
assets/service-account.json
Normal file
13
assets/service-account.json
Normal 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
55
build_release.sh
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# Flutter APK Build Script (AAB Disabled)
|
||||||
|
# ===============================
|
||||||
|
|
||||||
|
# Exit immediately if a command exits with a non-zero status
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for pretty output
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# App info
|
||||||
|
APP_NAME="Marco"
|
||||||
|
BUILD_DIR="build/app/outputs"
|
||||||
|
|
||||||
|
echo -e "${CYAN}🚀 Starting Flutter build script for $APP_NAME...${NC}"
|
||||||
|
|
||||||
|
# Step 1: Clean previous builds
|
||||||
|
echo -e "${YELLOW}🧹 Cleaning previous builds...${NC}"
|
||||||
|
flutter clean
|
||||||
|
|
||||||
|
# Step 2: Get dependencies
|
||||||
|
echo -e "${YELLOW}📦 Fetching dependencies...${NC}"
|
||||||
|
flutter pub get
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Step 3: Build AAB (Commented)
|
||||||
|
# ==============================
|
||||||
|
# echo -e "${CYAN}🏗 Building AAB file...${NC}"
|
||||||
|
# flutter build appbundle --release
|
||||||
|
|
||||||
|
# Step 4: Build APK
|
||||||
|
echo -e "${CYAN}🏗 Building APK file...${NC}"
|
||||||
|
flutter build apk --release
|
||||||
|
|
||||||
|
# Step 5: Show output paths
|
||||||
|
# AAB_PATH="$BUILD_DIR/bundle/release/app-release.aab"
|
||||||
|
APK_PATH="$BUILD_DIR/apk/release/app-release.apk"
|
||||||
|
|
||||||
|
echo -e "${GREEN}✅ Build completed successfully!${NC}"
|
||||||
|
# echo -e "${YELLOW}📍 AAB file: ${CYAN}$AAB_PATH${NC}"
|
||||||
|
echo -e "${YELLOW}📍 APK file: ${CYAN}$APK_PATH${NC}"
|
||||||
|
|
||||||
|
# Optional: open the folder (Mac/Linux)
|
||||||
|
if command -v xdg-open &> /dev/null
|
||||||
|
then
|
||||||
|
xdg-open "$BUILD_DIR"
|
||||||
|
elif command -v open &> /dev/null
|
||||||
|
then
|
||||||
|
open "$BUILD_DIR"
|
||||||
|
fi
|
||||||
3
devtools_options.yaml
Normal file
3
devtools_options.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
@ -368,7 +368,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco;
|
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.example.marco.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.example.marco.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.example.marco.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.example.marco;
|
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.example.marco;
|
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;
|
||||||
|
|||||||
426
lib/controller/attendance/attendance_screen_controller.dart
Normal file
426
lib/controller/attendance/attendance_screen_controller.dart
Normal file
@ -0,0 +1,426 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_image_compressor.dart';
|
||||||
|
|
||||||
|
import 'package:marco/model/attendance/attendance_model.dart';
|
||||||
|
import 'package:marco/model/project_model.dart';
|
||||||
|
import 'package:marco/model/employees/employee_model.dart';
|
||||||
|
import 'package:marco/model/attendance/attendance_log_model.dart';
|
||||||
|
import 'package:marco/model/regularization_log_model.dart';
|
||||||
|
import 'package:marco/model/attendance/attendance_log_view_model.dart';
|
||||||
|
import 'package:marco/model/attendance/organization_per_project_list_model.dart';
|
||||||
|
import 'package:marco/controller/project_controller.dart';
|
||||||
|
|
||||||
|
class AttendanceController extends GetxController {
|
||||||
|
// Data models
|
||||||
|
List<AttendanceModel> attendances = [];
|
||||||
|
List<ProjectModel> projects = [];
|
||||||
|
List<EmployeeModel> employees = [];
|
||||||
|
List<AttendanceLogModel> attendanceLogs = [];
|
||||||
|
List<RegularizationLogModel> regularizationLogs = [];
|
||||||
|
List<AttendanceLogViewModel> attendenceLogsView = [];
|
||||||
|
// ------------------ Organizations ------------------
|
||||||
|
List<Organization> organizations = [];
|
||||||
|
Organization? selectedOrganization;
|
||||||
|
final isLoadingOrganizations = false.obs;
|
||||||
|
|
||||||
|
// States
|
||||||
|
String selectedTab = 'todaysAttendance';
|
||||||
|
DateTime? startDateAttendance;
|
||||||
|
DateTime? endDateAttendance;
|
||||||
|
|
||||||
|
final isLoading = true.obs;
|
||||||
|
final isLoadingProjects = true.obs;
|
||||||
|
final isLoadingEmployees = true.obs;
|
||||||
|
final isLoadingAttendanceLogs = true.obs;
|
||||||
|
final isLoadingRegularizationLogs = true.obs;
|
||||||
|
final isLoadingLogView = true.obs;
|
||||||
|
final uploadingStates = <String, RxBool>{}.obs;
|
||||||
|
var showPendingOnly = false.obs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
_initializeDefaults();
|
||||||
|
|
||||||
|
// 🔹 Fetch organizations for the selected project
|
||||||
|
final projectId = Get.find<ProjectController>().selectedProject?.id;
|
||||||
|
if (projectId != null) {
|
||||||
|
fetchOrganizations(projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeDefaults() {
|
||||||
|
_setDefaultDateRange();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setDefaultDateRange() {
|
||||||
|
final today = DateTime.now();
|
||||||
|
startDateAttendance = today.subtract(const Duration(days: 7));
|
||||||
|
endDateAttendance = today.subtract(const Duration(days: 1));
|
||||||
|
logSafe(
|
||||||
|
"Default date range set: $startDateAttendance to $endDateAttendance");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------ Project & Employee ------------------
|
||||||
|
/// Called when a notification says attendance has been updated
|
||||||
|
Future<void> refreshDataFromNotification({String? projectId}) async {
|
||||||
|
projectId ??= Get.find<ProjectController>().selectedProject?.id;
|
||||||
|
if (projectId == null) {
|
||||||
|
logSafe("No project selected for attendance refresh from notification",
|
||||||
|
level: LogLevel.warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchProjectData(projectId);
|
||||||
|
logSafe(
|
||||||
|
"Attendance data refreshed from notification for project $projectId");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 Search query
|
||||||
|
final searchQuery = ''.obs;
|
||||||
|
|
||||||
|
// Computed filtered employees
|
||||||
|
List<EmployeeModel> get filteredEmployees {
|
||||||
|
if (searchQuery.value.isEmpty) return employees;
|
||||||
|
return employees
|
||||||
|
.where((e) =>
|
||||||
|
e.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed filtered logs
|
||||||
|
List<AttendanceLogModel> get filteredLogs {
|
||||||
|
if (searchQuery.value.isEmpty) return attendanceLogs;
|
||||||
|
return attendanceLogs
|
||||||
|
.where((log) =>
|
||||||
|
(log.name).toLowerCase().contains(searchQuery.value.toLowerCase()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed filtered regularization logs
|
||||||
|
List<RegularizationLogModel> get filteredRegularizationLogs {
|
||||||
|
if (searchQuery.value.isEmpty) return regularizationLogs;
|
||||||
|
return regularizationLogs
|
||||||
|
.where((log) =>
|
||||||
|
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchTodaysAttendance(String? projectId) async {
|
||||||
|
if (projectId == null) return;
|
||||||
|
|
||||||
|
isLoadingEmployees.value = true;
|
||||||
|
|
||||||
|
final response = await ApiService.getTodaysAttendance(
|
||||||
|
projectId,
|
||||||
|
organizationId: selectedOrganization?.id,
|
||||||
|
);
|
||||||
|
if (response != null) {
|
||||||
|
employees = response.map((e) => EmployeeModel.fromJson(e)).toList();
|
||||||
|
for (var emp in employees) {
|
||||||
|
uploadingStates[emp.id] = false.obs;
|
||||||
|
}
|
||||||
|
logSafe("Employees fetched: ${employees.length} for project $projectId");
|
||||||
|
} else {
|
||||||
|
logSafe("Failed to fetch employees for project $projectId",
|
||||||
|
level: LogLevel.error);
|
||||||
|
}
|
||||||
|
isLoadingEmployees.value = false;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchOrganizations(String projectId) async {
|
||||||
|
isLoadingOrganizations.value = true;
|
||||||
|
final response = await ApiService.getAssignedOrganizations(projectId);
|
||||||
|
if (response != null) {
|
||||||
|
organizations = response.data;
|
||||||
|
logSafe("Organizations fetched: ${organizations.length}");
|
||||||
|
} else {
|
||||||
|
logSafe("Failed to fetch organizations for project $projectId",
|
||||||
|
level: LogLevel.error);
|
||||||
|
}
|
||||||
|
isLoadingOrganizations.value = false;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------ Attendance Capture ------------------
|
||||||
|
|
||||||
|
Future<bool> captureAndUploadAttendance(
|
||||||
|
String id,
|
||||||
|
String employeeId,
|
||||||
|
String projectId, {
|
||||||
|
String comment = "Marked via mobile app",
|
||||||
|
required int action,
|
||||||
|
bool imageCapture = true,
|
||||||
|
String? markTime, // still optional in controller
|
||||||
|
String? date, // new optional param
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
uploadingStates[employeeId]?.value = true;
|
||||||
|
|
||||||
|
XFile? image;
|
||||||
|
if (imageCapture) {
|
||||||
|
image = await ImagePicker()
|
||||||
|
.pickImage(source: ImageSource.camera, imageQuality: 80);
|
||||||
|
if (image == null) {
|
||||||
|
logSafe("Image capture cancelled.", level: LogLevel.warning);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final compressedBytes =
|
||||||
|
await compressImageToUnder100KB(File(image.path));
|
||||||
|
if (compressedBytes == null) {
|
||||||
|
logSafe("Image compression failed.", level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final compressedFile = await saveCompressedImageToFile(compressedBytes);
|
||||||
|
image = XFile(compressedFile.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await _handleLocationPermission()) return false;
|
||||||
|
final position = await Geolocator.getCurrentPosition(
|
||||||
|
desiredAccuracy: LocationAccuracy.high);
|
||||||
|
|
||||||
|
final imageName = imageCapture
|
||||||
|
? ApiService.generateImageName(employeeId, employees.length + 1)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// ---------------- DATE / TIME LOGIC ----------------
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
// Default effectiveDate = now
|
||||||
|
DateTime effectiveDate = now;
|
||||||
|
|
||||||
|
if (action == 1) {
|
||||||
|
// Checkout
|
||||||
|
// Try to find today's open log for this employee
|
||||||
|
final log = attendanceLogs.firstWhereOrNull(
|
||||||
|
(log) => log.employeeId == employeeId && log.checkOut == null,
|
||||||
|
);
|
||||||
|
if (log?.checkIn != null) {
|
||||||
|
effectiveDate = log!.checkIn!; // use check-in date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final formattedMarkTime = markTime ?? DateFormat('hh:mm a').format(now);
|
||||||
|
|
||||||
|
final formattedDate =
|
||||||
|
date ?? DateFormat('yyyy-MM-dd').format(effectiveDate);
|
||||||
|
|
||||||
|
// ---------------- API CALL ----------------
|
||||||
|
final result = await ApiService.uploadAttendanceImage(
|
||||||
|
id,
|
||||||
|
employeeId,
|
||||||
|
image,
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
imageName: imageName,
|
||||||
|
projectId: projectId,
|
||||||
|
comment: comment,
|
||||||
|
action: action,
|
||||||
|
imageCapture: imageCapture,
|
||||||
|
markTime: formattedMarkTime,
|
||||||
|
date: formattedDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
logSafe(
|
||||||
|
"Attendance uploaded for $employeeId, action: $action, date: $formattedDate");
|
||||||
|
return result;
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
logSafe("Error uploading attendance",
|
||||||
|
level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
uploadingStates[employeeId]?.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _handleLocationPermission() async {
|
||||||
|
LocationPermission permission = await Geolocator.checkPermission();
|
||||||
|
|
||||||
|
if (permission == LocationPermission.denied) {
|
||||||
|
permission = await Geolocator.requestPermission();
|
||||||
|
if (permission == LocationPermission.denied) {
|
||||||
|
logSafe('Location permissions are denied', level: LogLevel.warning);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission == LocationPermission.deniedForever) {
|
||||||
|
logSafe('Location permissions are permanently denied',
|
||||||
|
level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------ Attendance Logs ------------------
|
||||||
|
|
||||||
|
Future<void> fetchAttendanceLogs(String? projectId,
|
||||||
|
{DateTime? dateFrom, DateTime? dateTo}) async {
|
||||||
|
if (projectId == null) return;
|
||||||
|
|
||||||
|
isLoadingAttendanceLogs.value = true;
|
||||||
|
|
||||||
|
final response = await ApiService.getAttendanceLogs(
|
||||||
|
projectId,
|
||||||
|
dateFrom: dateFrom,
|
||||||
|
dateTo: dateTo,
|
||||||
|
organizationId: selectedOrganization?.id,
|
||||||
|
);
|
||||||
|
if (response != null) {
|
||||||
|
attendanceLogs =
|
||||||
|
response.map((e) => AttendanceLogModel.fromJson(e)).toList();
|
||||||
|
logSafe("Attendance logs fetched: ${attendanceLogs.length}");
|
||||||
|
} else {
|
||||||
|
logSafe("Failed to fetch attendance logs for project $projectId",
|
||||||
|
level: LogLevel.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingAttendanceLogs.value = false;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, List<AttendanceLogModel>> groupLogsByCheckInDate() {
|
||||||
|
final groupedLogs = <String, List<AttendanceLogModel>>{};
|
||||||
|
|
||||||
|
for (var logItem in attendanceLogs) {
|
||||||
|
final checkInDate = logItem.checkIn != null
|
||||||
|
? DateFormat('dd MMM yyyy').format(logItem.checkIn!)
|
||||||
|
: 'Unknown';
|
||||||
|
groupedLogs.putIfAbsent(checkInDate, () => []).add(logItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
final sortedEntries = groupedLogs.entries.toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
if (a.key == 'Unknown') return 1;
|
||||||
|
if (b.key == 'Unknown') return -1;
|
||||||
|
final dateA = DateFormat('dd MMM yyyy').parse(a.key);
|
||||||
|
final dateB = DateFormat('dd MMM yyyy').parse(b.key);
|
||||||
|
return dateB.compareTo(dateA);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Map<String, List<AttendanceLogModel>>.fromEntries(sortedEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------ Regularization Logs ------------------
|
||||||
|
|
||||||
|
Future<void> fetchRegularizationLogs(String? projectId) async {
|
||||||
|
if (projectId == null) return;
|
||||||
|
|
||||||
|
isLoadingRegularizationLogs.value = true;
|
||||||
|
|
||||||
|
final response = await ApiService.getRegularizationLogs(
|
||||||
|
projectId,
|
||||||
|
organizationId: selectedOrganization?.id,
|
||||||
|
);
|
||||||
|
if (response != null) {
|
||||||
|
regularizationLogs =
|
||||||
|
response.map((e) => RegularizationLogModel.fromJson(e)).toList();
|
||||||
|
logSafe("Regularization logs fetched: ${regularizationLogs.length}");
|
||||||
|
} else {
|
||||||
|
logSafe("Failed to fetch regularization logs for project $projectId",
|
||||||
|
level: LogLevel.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingRegularizationLogs.value = false;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------ Attendance Log View ------------------
|
||||||
|
|
||||||
|
Future<void> fetchLogsView(String? id) async {
|
||||||
|
if (id == null) return;
|
||||||
|
|
||||||
|
isLoadingLogView.value = true;
|
||||||
|
|
||||||
|
final response = await ApiService.getAttendanceLogView(id);
|
||||||
|
if (response != null) {
|
||||||
|
attendenceLogsView =
|
||||||
|
response.map((e) => AttendanceLogViewModel.fromJson(e)).toList();
|
||||||
|
attendenceLogsView.sort((a, b) => (b.activityTime ?? DateTime(2000))
|
||||||
|
.compareTo(a.activityTime ?? DateTime(2000)));
|
||||||
|
logSafe("Attendance log view fetched for ID: $id");
|
||||||
|
} else {
|
||||||
|
logSafe("Failed to fetch attendance log view for ID $id",
|
||||||
|
level: LogLevel.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingLogView.value = false;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------ Combined Load ------------------
|
||||||
|
|
||||||
|
Future<void> loadAttendanceData(String projectId) async {
|
||||||
|
isLoading.value = true;
|
||||||
|
await fetchProjectData(projectId);
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchProjectData(String? projectId) async {
|
||||||
|
if (projectId == null) return;
|
||||||
|
|
||||||
|
await fetchOrganizations(projectId);
|
||||||
|
|
||||||
|
// Call APIs depending on the selected tab only
|
||||||
|
switch (selectedTab) {
|
||||||
|
case 'todaysAttendance':
|
||||||
|
await fetchTodaysAttendance(projectId);
|
||||||
|
break;
|
||||||
|
case 'attendanceLogs':
|
||||||
|
await fetchAttendanceLogs(
|
||||||
|
projectId,
|
||||||
|
dateFrom: startDateAttendance,
|
||||||
|
dateTo: endDateAttendance,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'regularizationRequests':
|
||||||
|
await fetchRegularizationLogs(projectId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe(
|
||||||
|
"Project data fetched for project ID: $projectId, tab: $selectedTab");
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------ UI Interaction ------------------
|
||||||
|
|
||||||
|
Future<void> selectDateRangeForAttendance(
|
||||||
|
BuildContext context, AttendanceController controller) async {
|
||||||
|
final today = DateTime.now();
|
||||||
|
|
||||||
|
final picked = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: DateTime(2022),
|
||||||
|
lastDate: today.subtract(const Duration(days: 1)),
|
||||||
|
initialDateRange: DateTimeRange(
|
||||||
|
start: startDateAttendance ?? today.subtract(const Duration(days: 7)),
|
||||||
|
end: endDateAttendance ?? today.subtract(const Duration(days: 1)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
startDateAttendance = picked.start;
|
||||||
|
endDateAttendance = picked.end;
|
||||||
|
logSafe(
|
||||||
|
"Date range selected: $startDateAttendance to $endDateAttendance");
|
||||||
|
|
||||||
|
await controller.fetchAttendanceLogs(
|
||||||
|
Get.find<ProjectController>().selectedProject?.id,
|
||||||
|
dateFrom: picked.start,
|
||||||
|
dateTo: picked.end,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,20 +4,25 @@ import 'package:marco/helpers/services/auth_service.dart';
|
|||||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
import 'package:marco/helpers/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();
|
||||||
final isNewUser = false.obs;
|
final isNewUser = false.obs;
|
||||||
|
final isChangeMpin = false.obs;
|
||||||
final RxBool isLoading = false.obs;
|
final RxBool isLoading = false.obs;
|
||||||
final formKey = GlobalKey<FormState>();
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
final digitControllers = List.generate(6, (_) => TextEditingController());
|
// Updated to 4-digit MPIN
|
||||||
final focusNodes = List.generate(6, (_) => FocusNode());
|
final digitControllers = List.generate(4, (_) => TextEditingController());
|
||||||
|
final focusNodes = List.generate(4, (_) => FocusNode());
|
||||||
|
|
||||||
|
final retypeControllers = List.generate(4, (_) => TextEditingController());
|
||||||
|
final retypeFocusNodes = List.generate(4, (_) => FocusNode());
|
||||||
|
|
||||||
final retypeControllers = List.generate(6, (_) => TextEditingController());
|
|
||||||
final retypeFocusNodes = List.generate(6, (_) => FocusNode());
|
|
||||||
final RxInt failedAttempts = 0.obs;
|
final RxInt failedAttempts = 0.obs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -28,16 +33,28 @@ class MPINController extends GetxController {
|
|||||||
logSafe("onInit called. isNewUser: ${isNewUser.value}");
|
logSafe("onInit called. isNewUser: ${isNewUser.value}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable Change MPIN mode
|
||||||
|
void setChangeMpinMode() {
|
||||||
|
isChangeMpin.value = true;
|
||||||
|
isNewUser.value = false;
|
||||||
|
clearFields();
|
||||||
|
clearRetypeFields();
|
||||||
|
logSafe("setChangeMpinMode activated");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle digit entry and focus movement
|
||||||
void onDigitChanged(String value, int index, {bool isRetype = false}) {
|
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 < 5) {
|
if (value.isNotEmpty && index < 3) {
|
||||||
nodes[index + 1].requestFocus();
|
nodes[index + 1].requestFocus();
|
||||||
} else if (value.isEmpty && index > 0) {
|
} else if (value.isEmpty && index > 0) {
|
||||||
nodes[index - 1].requestFocus();
|
nodes[index - 1].requestFocus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Submit MPIN for verification or generation
|
||||||
Future<void> onSubmitMPIN() async {
|
Future<void> onSubmitMPIN() async {
|
||||||
logSafe("onSubmitMPIN triggered");
|
logSafe("onSubmitMPIN triggered");
|
||||||
|
|
||||||
@ -47,19 +64,19 @@ class MPINController extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final enteredMPIN = digitControllers.map((c) => c.text).join();
|
final enteredMPIN = digitControllers.map((c) => c.text).join();
|
||||||
logSafe("Entered MPIN: $enteredMPIN", );
|
logSafe("Entered MPIN: $enteredMPIN");
|
||||||
|
|
||||||
if (enteredMPIN.length < 6) {
|
if (enteredMPIN.length < 4) {
|
||||||
_showError("Please enter all 6 digits.");
|
_showError("Please enter all 4 digits.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNewUser.value) {
|
if (isNewUser.value || isChangeMpin.value) {
|
||||||
final retypeMPIN = retypeControllers.map((c) => c.text).join();
|
final retypeMPIN = retypeControllers.map((c) => c.text).join();
|
||||||
logSafe("Retyped MPIN: $retypeMPIN", );
|
logSafe("Retyped MPIN: $retypeMPIN");
|
||||||
|
|
||||||
if (retypeMPIN.length < 6) {
|
if (retypeMPIN.length < 4) {
|
||||||
_showError("Please enter all 6 digits in Retype MPIN.");
|
_showError("Please enter all 4 digits in Retype MPIN.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,19 +87,20 @@ class MPINController extends GetxController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logSafe("MPINs matched. Proceeding to generate MPIN.");
|
|
||||||
final bool success = await generateMPIN(mpin: enteredMPIN);
|
final bool success = await generateMPIN(mpin: enteredMPIN);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
logSafe("MPIN generation successful.");
|
logSafe("MPIN generation/change successful.");
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Success",
|
title: "Success",
|
||||||
message: "MPIN generated successfully. Please login again.",
|
message: isChangeMpin.value
|
||||||
|
? "MPIN changed successfully."
|
||||||
|
: "MPIN generated successfully. Please login again.",
|
||||||
type: SnackbarType.success,
|
type: SnackbarType.success,
|
||||||
);
|
);
|
||||||
await LocalStorage.logout();
|
await LocalStorage.logout();
|
||||||
} else {
|
} else {
|
||||||
logSafe("MPIN generation failed.", level: LogLevel.warning);
|
logSafe("MPIN generation/change failed.", level: LogLevel.warning);
|
||||||
clearFields();
|
clearFields();
|
||||||
clearRetypeFields();
|
clearRetypeFields();
|
||||||
}
|
}
|
||||||
@ -92,20 +110,25 @@ class MPINController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forgot MPIN
|
||||||
Future<void> onForgotMPIN() async {
|
Future<void> onForgotMPIN() async {
|
||||||
logSafe("onForgotMPIN called");
|
logSafe("onForgotMPIN called");
|
||||||
isNewUser.value = true;
|
isNewUser.value = true;
|
||||||
|
isChangeMpin.value = false;
|
||||||
clearFields();
|
clearFields();
|
||||||
clearRetypeFields();
|
clearRetypeFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Switch to login/enter MPIN screen
|
||||||
void switchToEnterMPIN() {
|
void switchToEnterMPIN() {
|
||||||
logSafe("switchToEnterMPIN called");
|
logSafe("switchToEnterMPIN called");
|
||||||
isNewUser.value = false;
|
isNewUser.value = false;
|
||||||
|
isChangeMpin.value = false;
|
||||||
clearFields();
|
clearFields();
|
||||||
clearRetypeFields();
|
clearRetypeFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show error snackbar
|
||||||
void _showError(String message) {
|
void _showError(String message) {
|
||||||
logSafe("ERROR: $message", level: LogLevel.error);
|
logSafe("ERROR: $message", level: LogLevel.error);
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
@ -115,18 +138,21 @@ class MPINController extends GetxController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToDashboard({String? message}) {
|
/// Navigate to dashboard
|
||||||
|
/// Navigate to tenant selection after MPIN verification
|
||||||
|
void _navigateToTenantSelection({String? message}) {
|
||||||
if (message != null) {
|
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
|
||||||
void clearFields() {
|
void clearFields() {
|
||||||
logSafe("clearFields called");
|
logSafe("clearFields called");
|
||||||
for (final c in digitControllers) {
|
for (final c in digitControllers) {
|
||||||
@ -135,6 +161,7 @@ class MPINController extends GetxController {
|
|||||||
focusNodes.first.requestFocus();
|
focusNodes.first.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear the retype MPIN fields
|
||||||
void clearRetypeFields() {
|
void clearRetypeFields() {
|
||||||
logSafe("clearRetypeFields called");
|
logSafe("clearRetypeFields called");
|
||||||
for (final c in retypeControllers) {
|
for (final c in retypeControllers) {
|
||||||
@ -143,6 +170,7 @@ class MPINController extends GetxController {
|
|||||||
retypeFocusNodes.first.requestFocus();
|
retypeFocusNodes.first.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cleanup
|
||||||
@override
|
@override
|
||||||
void onClose() {
|
void onClose() {
|
||||||
logSafe("onClose called");
|
logSafe("onClose called");
|
||||||
@ -161,9 +189,8 @@ class MPINController extends GetxController {
|
|||||||
super.onClose();
|
super.onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> generateMPIN({
|
/// Generate MPIN for new user/change MPIN
|
||||||
required String mpin,
|
Future<bool> generateMPIN({required String mpin}) async {
|
||||||
}) async {
|
|
||||||
try {
|
try {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
logSafe("generateMPIN started");
|
logSafe("generateMPIN started");
|
||||||
@ -177,7 +204,7 @@ class MPINController extends GetxController {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
logSafe("Calling AuthService.generateMpin for employeeId: $employeeId", );
|
logSafe("Calling AuthService.generateMpin for employeeId: $employeeId");
|
||||||
|
|
||||||
final response = await AuthService.generateMpin(
|
final response = await AuthService.generateMpin(
|
||||||
employeeId: employeeId,
|
employeeId: employeeId,
|
||||||
@ -187,21 +214,12 @@ class MPINController extends GetxController {
|
|||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
|
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
logSafe("MPIN generated successfully");
|
|
||||||
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Success",
|
|
||||||
message: "MPIN generated successfully. Please login again.",
|
|
||||||
type: SnackbarType.success,
|
|
||||||
);
|
|
||||||
|
|
||||||
await LocalStorage.logout();
|
|
||||||
|
|
||||||
return true;
|
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 Generation Failed",
|
title: "MPIN Operation Failed",
|
||||||
message: "Please check your inputs.",
|
message: "Please check your inputs.",
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
@ -213,24 +231,22 @@ class MPINController extends GetxController {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
logSafe("Exception in generateMPIN", level: LogLevel.error, error: e);
|
logSafe("Exception in generateMPIN", level: LogLevel.error, error: e);
|
||||||
_showError("Failed to generate MPIN.");
|
_showError("Failed to process MPIN.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verify MPIN for existing user
|
||||||
Future<void> verifyMPIN() async {
|
Future<void> verifyMPIN() async {
|
||||||
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) {
|
||||||
|
_showError("Please enter all 4 digits.");
|
||||||
if (enteredMPIN.length < 6) {
|
|
||||||
_showError("Please enter all 6 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;
|
||||||
@ -239,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;
|
||||||
@ -250,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,
|
||||||
@ -270,14 +303,11 @@ 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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Increment failed attempts and warn
|
||||||
void onInvalidMPIN() {
|
void onInvalidMPIN() {
|
||||||
failedAttempts.value++;
|
failedAttempts.value++;
|
||||||
if (failedAttempts.value >= 3) {
|
if (failedAttempts.value >= 3) {
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -1,270 +0,0 @@
|
|||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:marco/controller/my_controller.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_form_validator.dart';
|
|
||||||
import 'package:marco/helpers/services/api_service.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
|
||||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
|
||||||
import 'package:marco/helpers/services/app_logger.dart';
|
|
||||||
|
|
||||||
enum Gender {
|
|
||||||
male,
|
|
||||||
female,
|
|
||||||
other;
|
|
||||||
|
|
||||||
const Gender();
|
|
||||||
}
|
|
||||||
|
|
||||||
class AddEmployeeController extends MyController {
|
|
||||||
List<PlatformFile> files = [];
|
|
||||||
final MyFormValidator basicValidator = MyFormValidator();
|
|
||||||
Gender? selectedGender;
|
|
||||||
List<Map<String, dynamic>> roles = [];
|
|
||||||
String? selectedRoleId;
|
|
||||||
final List<Map<String, String>> countries = [
|
|
||||||
{"code": "+91", "name": "India"},
|
|
||||||
{"code": "+1", "name": "USA"},
|
|
||||||
{"code": "+971", "name": "UAE"},
|
|
||||||
{"code": "+44", "name": "UK"},
|
|
||||||
{"code": "+81", "name": "Japan"},
|
|
||||||
{"code": "+61", "name": "Australia"},
|
|
||||||
{"code": "+49", "name": "Germany"},
|
|
||||||
{"code": "+33", "name": "France"},
|
|
||||||
{"code": "+86", "name": "China"},
|
|
||||||
];
|
|
||||||
|
|
||||||
final Map<String, int> minDigitsPerCountry = {
|
|
||||||
"+91": 10,
|
|
||||||
"+1": 10,
|
|
||||||
"+971": 9,
|
|
||||||
"+44": 10,
|
|
||||||
"+81": 10,
|
|
||||||
"+61": 9,
|
|
||||||
"+49": 10,
|
|
||||||
"+33": 9,
|
|
||||||
"+86": 11,
|
|
||||||
};
|
|
||||||
|
|
||||||
final Map<String, int> maxDigitsPerCountry = {
|
|
||||||
"+91": 10,
|
|
||||||
"+1": 10,
|
|
||||||
"+971": 9,
|
|
||||||
"+44": 11,
|
|
||||||
"+81": 10,
|
|
||||||
"+61": 9,
|
|
||||||
"+49": 11,
|
|
||||||
"+33": 9,
|
|
||||||
"+86": 11,
|
|
||||||
};
|
|
||||||
|
|
||||||
String selectedCountryCode = "+91";
|
|
||||||
bool showOnline = true;
|
|
||||||
final List<String> categories = [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onInit() {
|
|
||||||
super.onInit();
|
|
||||||
logSafe("Initializing AddEmployeeController...");
|
|
||||||
_initializeFields();
|
|
||||||
fetchRoles();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _initializeFields() {
|
|
||||||
basicValidator.addField(
|
|
||||||
'first_name',
|
|
||||||
label: "First Name",
|
|
||||||
required: true,
|
|
||||||
controller: TextEditingController(),
|
|
||||||
);
|
|
||||||
basicValidator.addField(
|
|
||||||
'phone_number',
|
|
||||||
label: "Phone Number",
|
|
||||||
required: true,
|
|
||||||
controller: TextEditingController(),
|
|
||||||
);
|
|
||||||
basicValidator.addField(
|
|
||||||
'last_name',
|
|
||||||
label: "Last Name",
|
|
||||||
required: true,
|
|
||||||
controller: TextEditingController(),
|
|
||||||
);
|
|
||||||
logSafe("Fields initialized for first_name, phone_number, last_name.");
|
|
||||||
}
|
|
||||||
|
|
||||||
void onGenderSelected(Gender? gender) {
|
|
||||||
selectedGender = gender;
|
|
||||||
logSafe("Gender selected: ${gender?.name}");
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchRoles() async {
|
|
||||||
logSafe("Fetching roles...");
|
|
||||||
try {
|
|
||||||
final result = await ApiService.getRoles();
|
|
||||||
if (result != null) {
|
|
||||||
roles = List<Map<String, dynamic>>.from(result);
|
|
||||||
logSafe("Roles fetched successfully.");
|
|
||||||
update();
|
|
||||||
} else {
|
|
||||||
logSafe("Failed to fetch roles: null result", level: LogLevel.error);
|
|
||||||
}
|
|
||||||
} catch (e, st) {
|
|
||||||
logSafe("Error fetching roles", level: LogLevel.error, error: e, stackTrace: st);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void onRoleSelected(String? roleId) {
|
|
||||||
selectedRoleId = roleId;
|
|
||||||
logSafe("Role selected: $roleId");
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> createEmployees() async {
|
|
||||||
logSafe("Starting employee creation...");
|
|
||||||
if (selectedGender == null || selectedRoleId == null) {
|
|
||||||
logSafe("Missing gender or role.", level: LogLevel.warning);
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Missing Fields",
|
|
||||||
message: "Please select both Gender and Role.",
|
|
||||||
type: SnackbarType.warning,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
final firstName = basicValidator.getController("first_name")?.text.trim();
|
|
||||||
final lastName = basicValidator.getController("last_name")?.text.trim();
|
|
||||||
final phoneNumber = basicValidator.getController("phone_number")?.text.trim();
|
|
||||||
|
|
||||||
logSafe("Creating employee", level: LogLevel.info);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final response = await ApiService.createEmployee(
|
|
||||||
firstName: firstName!,
|
|
||||||
lastName: lastName!,
|
|
||||||
phoneNumber: phoneNumber!,
|
|
||||||
gender: selectedGender!.name,
|
|
||||||
jobRoleId: selectedRoleId!,
|
|
||||||
);
|
|
||||||
logSafe("Response: $response");
|
|
||||||
if (response == true) {
|
|
||||||
logSafe("Employee created successfully.");
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Success",
|
|
||||||
message: "Employee created successfully!",
|
|
||||||
type: SnackbarType.success,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
logSafe("Failed to create employee (response false)", level: LogLevel.error);
|
|
||||||
}
|
|
||||||
} catch (e, st) {
|
|
||||||
logSafe("Error creating employee", level: LogLevel.error, error: e, stackTrace: st);
|
|
||||||
}
|
|
||||||
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Error",
|
|
||||||
message: "Failed to create employee.",
|
|
||||||
type: SnackbarType.error,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> _checkAndRequestContactsPermission() async {
|
|
||||||
final status = await Permission.contacts.request();
|
|
||||||
|
|
||||||
if (status.isGranted) return true;
|
|
||||||
|
|
||||||
if (status.isPermanentlyDenied) {
|
|
||||||
await openAppSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Permission Required",
|
|
||||||
message: "Please allow Contacts permission from settings to pick a contact.",
|
|
||||||
type: SnackbarType.warning,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> pickContact(BuildContext context) async {
|
|
||||||
final permissionGranted = await _checkAndRequestContactsPermission();
|
|
||||||
if (!permissionGranted) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final picked = await FlutterContacts.openExternalPick();
|
|
||||||
if (picked == null) return;
|
|
||||||
|
|
||||||
final contact = await FlutterContacts.getContact(picked.id, withProperties: true);
|
|
||||||
if (contact == null) {
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Error",
|
|
||||||
message: "Failed to load contact details.",
|
|
||||||
type: SnackbarType.error,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contact.phones.isEmpty) {
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "No Phone Number",
|
|
||||||
message: "Selected contact has no phone number.",
|
|
||||||
type: SnackbarType.warning,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final indiaPhones = contact.phones.where((p) {
|
|
||||||
final normalized = p.number.replaceAll(RegExp(r'[^0-9+]'), '');
|
|
||||||
return normalized.startsWith('+91') || RegExp(r'^\d{10}$').hasMatch(normalized);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
if (indiaPhones.isEmpty) {
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "No Indian Number",
|
|
||||||
message: "Selected contact has no Indian (+91) phone number.",
|
|
||||||
type: SnackbarType.warning,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? selectedPhone;
|
|
||||||
if (indiaPhones.length == 1) {
|
|
||||||
selectedPhone = indiaPhones.first.number;
|
|
||||||
} else {
|
|
||||||
selectedPhone = await showDialog<String>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: Text("Choose an Indian number"),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: indiaPhones
|
|
||||||
.map((p) => ListTile(
|
|
||||||
title: Text(p.number),
|
|
||||||
onTap: () => Navigator.of(ctx).pop(p.number),
|
|
||||||
))
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selectedPhone == null) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final normalizedPhone = selectedPhone.replaceAll(RegExp(r'[^0-9]'), '');
|
|
||||||
final phoneWithoutCountryCode = normalizedPhone.length > 10
|
|
||||||
? normalizedPhone.substring(normalizedPhone.length - 10)
|
|
||||||
: normalizedPhone;
|
|
||||||
|
|
||||||
basicValidator.getController('phone_number')?.text = phoneWithoutCountryCode;
|
|
||||||
update();
|
|
||||||
} catch (e, st) {
|
|
||||||
logSafe("Error fetching contacts", level: LogLevel.error, error: e, stackTrace: st);
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Error",
|
|
||||||
message: "Failed to fetch contacts.",
|
|
||||||
type: SnackbarType.error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,294 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:get/get.dart';
|
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
import 'package:geolocator/geolocator.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:marco/helpers/services/app_logger.dart';
|
|
||||||
import 'package:marco/helpers/services/api_service.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_image_compressor.dart';
|
|
||||||
import 'package:marco/model/attendance_model.dart';
|
|
||||||
import 'package:marco/model/project_model.dart';
|
|
||||||
import 'package:marco/model/employee_model.dart';
|
|
||||||
import 'package:marco/model/attendance_log_model.dart';
|
|
||||||
import 'package:marco/model/regularization_log_model.dart';
|
|
||||||
import 'package:marco/model/attendance_log_view_model.dart';
|
|
||||||
import 'package:marco/controller/project_controller.dart';
|
|
||||||
|
|
||||||
class AttendanceController extends GetxController {
|
|
||||||
List<AttendanceModel> attendances = [];
|
|
||||||
List<ProjectModel> projects = [];
|
|
||||||
List<EmployeeModel> employees = [];
|
|
||||||
List<AttendanceLogModel> attendanceLogs = [];
|
|
||||||
List<RegularizationLogModel> regularizationLogs = [];
|
|
||||||
List<AttendanceLogViewModel> attendenceLogsView = [];
|
|
||||||
|
|
||||||
String selectedTab = 'Employee List';
|
|
||||||
|
|
||||||
DateTime? startDateAttendance;
|
|
||||||
DateTime? endDateAttendance;
|
|
||||||
|
|
||||||
RxBool isLoading = true.obs;
|
|
||||||
RxBool isLoadingProjects = true.obs;
|
|
||||||
RxBool isLoadingEmployees = true.obs;
|
|
||||||
RxBool isLoadingAttendanceLogs = true.obs;
|
|
||||||
RxBool isLoadingRegularizationLogs = true.obs;
|
|
||||||
RxBool isLoadingLogView = true.obs;
|
|
||||||
|
|
||||||
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onInit() {
|
|
||||||
super.onInit();
|
|
||||||
_initializeDefaults();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _initializeDefaults() {
|
|
||||||
_setDefaultDateRange();
|
|
||||||
fetchProjects();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _setDefaultDateRange() {
|
|
||||||
final today = DateTime.now();
|
|
||||||
startDateAttendance = today.subtract(const Duration(days: 7));
|
|
||||||
endDateAttendance = today.subtract(const Duration(days: 1));
|
|
||||||
logSafe("Default date range set: $startDateAttendance to $endDateAttendance");
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> _handleLocationPermission() async {
|
|
||||||
LocationPermission permission = await Geolocator.checkPermission();
|
|
||||||
if (permission == LocationPermission.denied) {
|
|
||||||
permission = await Geolocator.requestPermission();
|
|
||||||
if (permission == LocationPermission.denied) {
|
|
||||||
logSafe('Location permissions are denied', level: LogLevel.warning);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (permission == LocationPermission.deniedForever) {
|
|
||||||
logSafe('Location permissions are permanently denied', level: LogLevel.error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchProjects() async {
|
|
||||||
isLoadingProjects.value = true;
|
|
||||||
isLoading.value = true;
|
|
||||||
|
|
||||||
final response = await ApiService.getProjects();
|
|
||||||
if (response != null && response.isNotEmpty) {
|
|
||||||
projects = response.map((json) => ProjectModel.fromJson(json)).toList();
|
|
||||||
logSafe("Projects fetched: ${projects.length}");
|
|
||||||
} else {
|
|
||||||
logSafe("Failed to fetch projects or no projects available.", level: LogLevel.error);
|
|
||||||
projects = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoadingProjects.value = false;
|
|
||||||
isLoading.value = false;
|
|
||||||
update(['attendance_dashboard_controller']);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> loadAttendanceData(String projectId) async {
|
|
||||||
await fetchEmployeesByProject(projectId);
|
|
||||||
await fetchAttendanceLogs(projectId);
|
|
||||||
await fetchRegularizationLogs(projectId);
|
|
||||||
await fetchProjectData(projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchProjectData(String? projectId) async {
|
|
||||||
if (projectId == null) return;
|
|
||||||
isLoading.value = true;
|
|
||||||
await Future.wait([
|
|
||||||
fetchEmployeesByProject(projectId),
|
|
||||||
fetchAttendanceLogs(projectId, dateFrom: startDateAttendance, dateTo: endDateAttendance),
|
|
||||||
fetchRegularizationLogs(projectId),
|
|
||||||
]);
|
|
||||||
isLoading.value = false;
|
|
||||||
logSafe("Project data fetched for project ID: $projectId");
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchEmployeesByProject(String? projectId) async {
|
|
||||||
if (projectId == null) return;
|
|
||||||
isLoadingEmployees.value = true;
|
|
||||||
final response = await ApiService.getEmployeesByProject(projectId);
|
|
||||||
if (response != null) {
|
|
||||||
employees = response.map((json) => EmployeeModel.fromJson(json)).toList();
|
|
||||||
for (var emp in employees) {
|
|
||||||
uploadingStates[emp.id] = false.obs;
|
|
||||||
}
|
|
||||||
logSafe("Employees fetched: ${employees.length} for project $projectId");
|
|
||||||
update();
|
|
||||||
} else {
|
|
||||||
logSafe("Failed to fetch employees for project $projectId", level: LogLevel.error);
|
|
||||||
}
|
|
||||||
isLoadingEmployees.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> captureAndUploadAttendance(
|
|
||||||
String id,
|
|
||||||
String employeeId,
|
|
||||||
String projectId, {
|
|
||||||
String comment = "Marked via mobile app",
|
|
||||||
required int action,
|
|
||||||
bool imageCapture = true,
|
|
||||||
String? markTime,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
uploadingStates[employeeId]?.value = true;
|
|
||||||
XFile? image;
|
|
||||||
if (imageCapture) {
|
|
||||||
image = await ImagePicker().pickImage(source: ImageSource.camera, imageQuality: 80);
|
|
||||||
if (image == null) {
|
|
||||||
logSafe("Image capture cancelled.", level: LogLevel.warning);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
final compressedBytes = await compressImageToUnder100KB(File(image.path));
|
|
||||||
if (compressedBytes == null) {
|
|
||||||
logSafe("Image compression failed.", level: LogLevel.error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
final compressedFile = await saveCompressedImageToFile(compressedBytes);
|
|
||||||
image = XFile(compressedFile.path);
|
|
||||||
}
|
|
||||||
final hasLocationPermission = await _handleLocationPermission();
|
|
||||||
if (!hasLocationPermission) return false;
|
|
||||||
final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
|
|
||||||
final imageName = imageCapture ? ApiService.generateImageName(employeeId, employees.length + 1) : "";
|
|
||||||
|
|
||||||
final result = await ApiService.uploadAttendanceImage(
|
|
||||||
id, employeeId, image, position.latitude, position.longitude,
|
|
||||||
imageName: imageName, projectId: projectId, comment: comment,
|
|
||||||
action: action, imageCapture: imageCapture, markTime: markTime,
|
|
||||||
);
|
|
||||||
logSafe("Attendance uploaded for $employeeId, action: $action");
|
|
||||||
return result;
|
|
||||||
} catch (e, stacktrace) {
|
|
||||||
logSafe("Error uploading attendance", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
uploadingStates[employeeId]?.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> selectDateRangeForAttendance(BuildContext context, AttendanceController controller) async {
|
|
||||||
final today = DateTime.now();
|
|
||||||
final picked = await showDateRangePicker(
|
|
||||||
context: context,
|
|
||||||
firstDate: DateTime(2022),
|
|
||||||
lastDate: today.subtract(const Duration(days: 1)),
|
|
||||||
initialDateRange: DateTimeRange(
|
|
||||||
start: startDateAttendance ?? today.subtract(const Duration(days: 7)),
|
|
||||||
end: endDateAttendance ?? today.subtract(const Duration(days: 1)),
|
|
||||||
),
|
|
||||||
builder: (context, child) {
|
|
||||||
return Center(
|
|
||||||
child: SizedBox(
|
|
||||||
width: 400,
|
|
||||||
height: 500,
|
|
||||||
child: Theme(
|
|
||||||
data: Theme.of(context).copyWith(
|
|
||||||
colorScheme: ColorScheme.light(
|
|
||||||
primary: const Color.fromARGB(255, 95, 132, 255),
|
|
||||||
onPrimary: Colors.white,
|
|
||||||
onSurface: Colors.teal.shade800,
|
|
||||||
),
|
|
||||||
textButtonTheme: TextButtonThemeData(
|
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.teal),
|
|
||||||
),
|
|
||||||
dialogTheme: DialogThemeData(backgroundColor: Colors.white),
|
|
||||||
),
|
|
||||||
child: child!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (picked != null) {
|
|
||||||
startDateAttendance = picked.start;
|
|
||||||
endDateAttendance = picked.end;
|
|
||||||
logSafe("Date range selected: $startDateAttendance to $endDateAttendance");
|
|
||||||
await controller.fetchAttendanceLogs(
|
|
||||||
Get.find<ProjectController>().selectedProject?.id,
|
|
||||||
dateFrom: picked.start,
|
|
||||||
dateTo: picked.end,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchAttendanceLogs(String? projectId, {DateTime? dateFrom, DateTime? dateTo}) async {
|
|
||||||
if (projectId == null) return;
|
|
||||||
isLoadingAttendanceLogs.value = true;
|
|
||||||
isLoading.value = true;
|
|
||||||
final response = await ApiService.getAttendanceLogs(projectId, dateFrom: dateFrom, dateTo: dateTo);
|
|
||||||
if (response != null) {
|
|
||||||
attendanceLogs = response.map((json) => AttendanceLogModel.fromJson(json)).toList();
|
|
||||||
logSafe("Attendance logs fetched: ${attendanceLogs.length}");
|
|
||||||
update();
|
|
||||||
} else {
|
|
||||||
logSafe("Failed to fetch attendance logs for project $projectId", level: LogLevel.error);
|
|
||||||
}
|
|
||||||
isLoadingAttendanceLogs.value = false;
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, List<AttendanceLogModel>> groupLogsByCheckInDate() {
|
|
||||||
final groupedLogs = <String, List<AttendanceLogModel>>{};
|
|
||||||
for (var logItem in attendanceLogs) {
|
|
||||||
final checkInDate = logItem.checkIn != null
|
|
||||||
? DateFormat('dd MMM yyyy').format(logItem.checkIn!)
|
|
||||||
: 'Unknown';
|
|
||||||
groupedLogs.putIfAbsent(checkInDate, () => []);
|
|
||||||
groupedLogs[checkInDate]!.add(logItem);
|
|
||||||
}
|
|
||||||
final sortedEntries = groupedLogs.entries.toList()
|
|
||||||
..sort((a, b) {
|
|
||||||
if (a.key == 'Unknown') return 1;
|
|
||||||
if (b.key == 'Unknown') return -1;
|
|
||||||
final dateA = DateFormat('dd MMM yyyy').parse(a.key);
|
|
||||||
final dateB = DateFormat('dd MMM yyyy').parse(b.key);
|
|
||||||
return dateB.compareTo(dateA);
|
|
||||||
});
|
|
||||||
final sortedMap = Map<String, List<AttendanceLogModel>>.fromEntries(sortedEntries);
|
|
||||||
logSafe("Logs grouped and sorted by check-in date.");
|
|
||||||
return sortedMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchRegularizationLogs(String? projectId, {DateTime? dateFrom, DateTime? dateTo}) async {
|
|
||||||
if (projectId == null) return;
|
|
||||||
isLoadingRegularizationLogs.value = true;
|
|
||||||
isLoading.value = true;
|
|
||||||
final response = await ApiService.getRegularizationLogs(projectId);
|
|
||||||
if (response != null) {
|
|
||||||
regularizationLogs = response.map((json) => RegularizationLogModel.fromJson(json)).toList();
|
|
||||||
logSafe("Regularization logs fetched: ${regularizationLogs.length}");
|
|
||||||
update();
|
|
||||||
} else {
|
|
||||||
logSafe("Failed to fetch regularization logs for project $projectId", level: LogLevel.error);
|
|
||||||
}
|
|
||||||
isLoadingRegularizationLogs.value = false;
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchLogsView(String? id) async {
|
|
||||||
if (id == null) return;
|
|
||||||
isLoadingLogView.value = true;
|
|
||||||
isLoading.value = true;
|
|
||||||
final response = await ApiService.getAttendanceLogView(id);
|
|
||||||
if (response != null) {
|
|
||||||
attendenceLogsView = response.map((json) => AttendanceLogViewModel.fromJson(json)).toList();
|
|
||||||
attendenceLogsView.sort((a, b) {
|
|
||||||
if (a.activityTime == null || b.activityTime == null) return 0;
|
|
||||||
return b.activityTime!.compareTo(a.activityTime!);
|
|
||||||
});
|
|
||||||
logSafe("Attendance log view fetched for ID: $id");
|
|
||||||
update();
|
|
||||||
} else {
|
|
||||||
logSafe("Failed to fetch attendance log view for ID $id", level: LogLevel.error);
|
|
||||||
}
|
|
||||||
isLoadingLogView.value = false;
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ class AddContactController extends GetxController {
|
|||||||
final RxList<String> tags = <String>[].obs;
|
final RxList<String> tags = <String>[].obs;
|
||||||
|
|
||||||
final RxString selectedCategory = ''.obs;
|
final RxString selectedCategory = ''.obs;
|
||||||
final RxString selectedBucket = ''.obs;
|
final RxList<String> selectedBuckets = <String>[].obs;
|
||||||
final RxString selectedProject = ''.obs;
|
final RxString selectedProject = ''.obs;
|
||||||
|
|
||||||
final RxList<String> enteredTags = <String>[].obs;
|
final RxList<String> enteredTags = <String>[].obs;
|
||||||
@ -24,6 +24,7 @@ class AddContactController extends GetxController {
|
|||||||
final RxMap<String, String> tagsMap = <String, String>{}.obs;
|
final RxMap<String, String> tagsMap = <String, String>{}.obs;
|
||||||
final RxBool isInitialized = false.obs;
|
final RxBool isInitialized = false.obs;
|
||||||
final RxList<String> selectedProjects = <String>[].obs;
|
final RxList<String> selectedProjects = <String>[].obs;
|
||||||
|
final RxBool isSubmitting = false.obs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
@ -49,7 +50,7 @@ class AddContactController extends GetxController {
|
|||||||
void resetForm() {
|
void resetForm() {
|
||||||
selectedCategory.value = '';
|
selectedCategory.value = '';
|
||||||
selectedProject.value = '';
|
selectedProject.value = '';
|
||||||
selectedBucket.value = '';
|
selectedBuckets.clear();
|
||||||
enteredTags.clear();
|
enteredTags.clear();
|
||||||
filteredSuggestions.clear();
|
filteredSuggestions.clear();
|
||||||
filteredOrgSuggestions.clear();
|
filteredOrgSuggestions.clear();
|
||||||
@ -93,21 +94,39 @@ class AddContactController extends GetxController {
|
|||||||
required List<Map<String, String>> phones,
|
required List<Map<String, String>> phones,
|
||||||
required String address,
|
required String address,
|
||||||
required String description,
|
required String description,
|
||||||
|
String? designation,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (isSubmitting.value) return;
|
||||||
|
isSubmitting.value = true;
|
||||||
|
|
||||||
final categoryId = categoriesMap[selectedCategory.value];
|
final categoryId = categoriesMap[selectedCategory.value];
|
||||||
final bucketId = bucketsMap[selectedBucket.value];
|
final bucketIds = selectedBuckets
|
||||||
|
.map((name) => bucketsMap[name])
|
||||||
|
.whereType<String>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (bucketIds.isEmpty) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Missing Buckets",
|
||||||
|
message: "Please select at least one bucket.",
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
isSubmitting.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final projectIds = selectedProjects
|
final projectIds = selectedProjects
|
||||||
.map((name) => projectsMap[name])
|
.map((name) => projectsMap[name])
|
||||||
.whereType<String>()
|
.whereType<String>()
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// === Required validations only for name, organization, and bucket ===
|
|
||||||
if (name.trim().isEmpty) {
|
if (name.trim().isEmpty) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Missing Name",
|
title: "Missing Name",
|
||||||
message: "Please enter the contact name.",
|
message: "Please enter the contact name.",
|
||||||
type: SnackbarType.warning,
|
type: SnackbarType.warning,
|
||||||
);
|
);
|
||||||
|
isSubmitting.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,19 +136,20 @@ class AddContactController extends GetxController {
|
|||||||
message: "Please enter the organization name.",
|
message: "Please enter the organization name.",
|
||||||
type: SnackbarType.warning,
|
type: SnackbarType.warning,
|
||||||
);
|
);
|
||||||
|
isSubmitting.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedBucket.value.trim().isEmpty || bucketId == null) {
|
if (selectedBuckets.isEmpty) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Missing Bucket",
|
title: "Missing Bucket",
|
||||||
message: "Please select a bucket.",
|
message: "Please select at least one bucket.",
|
||||||
type: SnackbarType.warning,
|
type: SnackbarType.warning,
|
||||||
);
|
);
|
||||||
|
isSubmitting.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Build body (include optional fields if available) ===
|
|
||||||
try {
|
try {
|
||||||
final tagObjects = enteredTags.map((tagName) {
|
final tagObjects = enteredTags.map((tagName) {
|
||||||
final tagId = tagsMap[tagName];
|
final tagId = tagsMap[tagName];
|
||||||
@ -145,12 +165,14 @@ class AddContactController extends GetxController {
|
|||||||
if (selectedCategory.value.isNotEmpty && categoryId != null)
|
if (selectedCategory.value.isNotEmpty && categoryId != null)
|
||||||
"contactCategoryId": categoryId,
|
"contactCategoryId": categoryId,
|
||||||
if (projectIds.isNotEmpty) "projectIds": projectIds,
|
if (projectIds.isNotEmpty) "projectIds": projectIds,
|
||||||
"bucketIds": [bucketId],
|
"bucketIds": bucketIds,
|
||||||
if (enteredTags.isNotEmpty) "tags": tagObjects,
|
if (enteredTags.isNotEmpty) "tags": tagObjects,
|
||||||
if (emails.isNotEmpty) "contactEmails": emails,
|
if (emails.isNotEmpty) "contactEmails": emails,
|
||||||
if (phones.isNotEmpty) "contactPhones": phones,
|
if (phones.isNotEmpty) "contactPhones": phones,
|
||||||
if (address.trim().isNotEmpty) "address": address.trim(),
|
if (address.trim().isNotEmpty) "address": address.trim(),
|
||||||
if (description.trim().isNotEmpty) "description": description.trim(),
|
if (description.trim().isNotEmpty) "description": description.trim(),
|
||||||
|
if (designation != null && designation.trim().isNotEmpty)
|
||||||
|
"designation": designation.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
logSafe("${id != null ? 'Updating' : 'Creating'} contact");
|
logSafe("${id != null ? 'Updating' : 'Creating'} contact");
|
||||||
@ -182,6 +204,8 @@ class AddContactController extends GetxController {
|
|||||||
message: "Something went wrong",
|
message: "Something went wrong",
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,19 +318,14 @@ class DirectoryController extends GetxController {
|
|||||||
final bucketMatch = selectedBuckets.isEmpty ||
|
final bucketMatch = selectedBuckets.isEmpty ||
|
||||||
contact.bucketIds.any((id) => selectedBuckets.contains(id));
|
contact.bucketIds.any((id) => selectedBuckets.contains(id));
|
||||||
|
|
||||||
// Name, org, email, phone, tags
|
|
||||||
final nameMatch = contact.name.toLowerCase().contains(query);
|
final nameMatch = contact.name.toLowerCase().contains(query);
|
||||||
final orgMatch = contact.organization.toLowerCase().contains(query);
|
final orgMatch = contact.organization.toLowerCase().contains(query);
|
||||||
|
|
||||||
final emailMatch = contact.contactEmails
|
final emailMatch = contact.contactEmails
|
||||||
.any((e) => e.emailAddress.toLowerCase().contains(query));
|
.any((e) => e.emailAddress.toLowerCase().contains(query));
|
||||||
|
|
||||||
final phoneMatch = contact.contactPhones
|
final phoneMatch = contact.contactPhones
|
||||||
.any((p) => p.phoneNumber.toLowerCase().contains(query));
|
.any((p) => p.phoneNumber.toLowerCase().contains(query));
|
||||||
|
|
||||||
final tagMatch =
|
final tagMatch =
|
||||||
contact.tags.any((tag) => tag.name.toLowerCase().contains(query));
|
contact.tags.any((tag) => tag.name.toLowerCase().contains(query));
|
||||||
|
|
||||||
final categoryNameMatch =
|
final categoryNameMatch =
|
||||||
contact.contactCategory?.name.toLowerCase().contains(query) ?? false;
|
contact.contactCategory?.name.toLowerCase().contains(query) ?? false;
|
||||||
|
|
||||||
@ -218,6 +349,9 @@ class DirectoryController extends GetxController {
|
|||||||
|
|
||||||
return categoryMatch && bucketMatch && searchMatch;
|
return categoryMatch && bucketMatch && searchMatch;
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
filteredContacts
|
||||||
|
.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggleCategory(String categoryId) {
|
void toggleCategory(String categoryId) {
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
82
lib/controller/document/document_details_controller.dart
Normal file
82
lib/controller/document/document_details_controller.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
239
lib/controller/document/document_upload_controller.dart
Normal file
239
lib/controller/document/document_upload_controller.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
181
lib/controller/document/user_document_controller.dart
Normal file
181
lib/controller/document/user_document_controller.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
lib/controller/dynamicMenu/dynamic_menu_controller.dart
Normal file
62
lib/controller/dynamicMenu/dynamic_menu_controller.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
310
lib/controller/employee/add_employee_controller.dart
Normal file
310
lib/controller/employee/add_employee_controller.dart
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/controller/my_controller.dart';
|
||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_form_validator.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
enum Gender {
|
||||||
|
male,
|
||||||
|
female,
|
||||||
|
other;
|
||||||
|
|
||||||
|
const Gender();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddEmployeeController extends MyController {
|
||||||
|
Map<String, dynamic>? editingEmployeeData;
|
||||||
|
|
||||||
|
// State
|
||||||
|
final MyFormValidator basicValidator = MyFormValidator();
|
||||||
|
final List<PlatformFile> files = [];
|
||||||
|
final List<String> categories = [];
|
||||||
|
|
||||||
|
Gender? selectedGender;
|
||||||
|
List<Map<String, dynamic>> roles = [];
|
||||||
|
String? selectedRoleId;
|
||||||
|
String selectedCountryCode = '+91';
|
||||||
|
bool showOnline = true;
|
||||||
|
DateTime? joiningDate;
|
||||||
|
String? selectedOrganizationId;
|
||||||
|
RxString selectedOrganizationName = RxString('');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
logSafe('Initializing AddEmployeeController...');
|
||||||
|
_initializeFields();
|
||||||
|
fetchRoles();
|
||||||
|
|
||||||
|
if (editingEmployeeData != null) {
|
||||||
|
prefillFields();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeFields() {
|
||||||
|
basicValidator.addField(
|
||||||
|
'first_name',
|
||||||
|
label: 'First Name',
|
||||||
|
required: true,
|
||||||
|
controller: TextEditingController(),
|
||||||
|
);
|
||||||
|
basicValidator.addField(
|
||||||
|
'phone_number',
|
||||||
|
label: 'Phone Number',
|
||||||
|
required: true,
|
||||||
|
controller: TextEditingController(),
|
||||||
|
);
|
||||||
|
basicValidator.addField(
|
||||||
|
'last_name',
|
||||||
|
label: 'Last Name',
|
||||||
|
required: true,
|
||||||
|
controller: TextEditingController(),
|
||||||
|
);
|
||||||
|
// Email is optional in controller; UI enforces when application access is checked
|
||||||
|
basicValidator.addField(
|
||||||
|
'email',
|
||||||
|
label: 'Email',
|
||||||
|
required: false,
|
||||||
|
controller: TextEditingController(),
|
||||||
|
);
|
||||||
|
|
||||||
|
logSafe('Fields initialized for first_name, phone_number, last_name, email.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefill fields in edit mode
|
||||||
|
void prefillFields() {
|
||||||
|
logSafe('Prefilling data for editing...');
|
||||||
|
basicValidator.getController('first_name')?.text =
|
||||||
|
editingEmployeeData?['first_name'] ?? '';
|
||||||
|
basicValidator.getController('last_name')?.text =
|
||||||
|
editingEmployeeData?['last_name'] ?? '';
|
||||||
|
basicValidator.getController('phone_number')?.text =
|
||||||
|
editingEmployeeData?['phone_number'] ?? '';
|
||||||
|
|
||||||
|
selectedGender = editingEmployeeData?['gender'] != null
|
||||||
|
? Gender.values.firstWhereOrNull((g) => g.name == editingEmployeeData!['gender'])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
basicValidator.getController('email')?.text =
|
||||||
|
editingEmployeeData?['email'] ?? '';
|
||||||
|
|
||||||
|
selectedRoleId = editingEmployeeData?['job_role_id'];
|
||||||
|
|
||||||
|
if (editingEmployeeData?['joining_date'] != null) {
|
||||||
|
joiningDate = DateTime.tryParse(editingEmployeeData!['joining_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setJoiningDate(DateTime date) {
|
||||||
|
joiningDate = date;
|
||||||
|
logSafe('Joining date selected: $date');
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void onGenderSelected(Gender? gender) {
|
||||||
|
selectedGender = gender;
|
||||||
|
logSafe('Gender selected: ${gender?.name}');
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchRoles() async {
|
||||||
|
logSafe('Fetching roles...');
|
||||||
|
try {
|
||||||
|
final result = await ApiService.getRoles();
|
||||||
|
if (result != null) {
|
||||||
|
roles = List<Map<String, dynamic>>.from(result);
|
||||||
|
logSafe('Roles fetched successfully.');
|
||||||
|
update();
|
||||||
|
} else {
|
||||||
|
logSafe('Failed to fetch roles: null result', level: LogLevel.error);
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
logSafe('Error fetching roles', level: LogLevel.error, error: e, stackTrace: st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onRoleSelected(String? roleId) {
|
||||||
|
selectedRoleId = roleId;
|
||||||
|
logSafe('Role selected: $roleId');
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update employee
|
||||||
|
Future<Map<String, dynamic>?> createOrUpdateEmployee({
|
||||||
|
String? email,
|
||||||
|
bool hasApplicationAccess = false,
|
||||||
|
}) async {
|
||||||
|
logSafe(editingEmployeeData != null
|
||||||
|
? 'Starting employee update...'
|
||||||
|
: 'Starting employee creation...');
|
||||||
|
|
||||||
|
if (selectedGender == null || selectedRoleId == null) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Missing Fields',
|
||||||
|
message: 'Please select both Gender and Role.',
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final firstName = basicValidator.getController('first_name')?.text.trim();
|
||||||
|
final lastName = basicValidator.getController('last_name')?.text.trim();
|
||||||
|
final phoneNumber = basicValidator.getController('phone_number')?.text.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// sanitize orgId before sending
|
||||||
|
final String? orgId = (selectedOrganizationId != null &&
|
||||||
|
selectedOrganizationId!.trim().isNotEmpty)
|
||||||
|
? selectedOrganizationId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final response = await ApiService.createEmployee(
|
||||||
|
id: editingEmployeeData?['id'],
|
||||||
|
firstName: firstName!,
|
||||||
|
lastName: lastName!,
|
||||||
|
phoneNumber: phoneNumber!,
|
||||||
|
gender: selectedGender!.name,
|
||||||
|
jobRoleId: selectedRoleId!,
|
||||||
|
joiningDate: joiningDate?.toIso8601String() ?? '',
|
||||||
|
organizationId: orgId,
|
||||||
|
email: email,
|
||||||
|
hasApplicationAccess: hasApplicationAccess,
|
||||||
|
);
|
||||||
|
|
||||||
|
logSafe('Response: $response');
|
||||||
|
|
||||||
|
if (response != null && response['success'] == true) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Success',
|
||||||
|
message: editingEmployeeData != null
|
||||||
|
? 'Employee updated successfully!'
|
||||||
|
: 'Employee created successfully!',
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
} else {
|
||||||
|
logSafe('Failed operation', level: LogLevel.error);
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
logSafe('Error creating/updating employee',
|
||||||
|
level: LogLevel.error, error: e, stackTrace: st);
|
||||||
|
}
|
||||||
|
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to save employee.',
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _checkAndRequestContactsPermission() async {
|
||||||
|
final status = await Permission.contacts.request();
|
||||||
|
|
||||||
|
if (status.isGranted) return true;
|
||||||
|
|
||||||
|
if (status.isPermanentlyDenied) {
|
||||||
|
await openAppSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Permission Required',
|
||||||
|
message: 'Please allow Contacts permission from settings to pick a contact.',
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pickContact(BuildContext context) async {
|
||||||
|
final permissionGranted = await _checkAndRequestContactsPermission();
|
||||||
|
if (!permissionGranted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final picked = await FlutterContacts.openExternalPick();
|
||||||
|
if (picked == null) return;
|
||||||
|
|
||||||
|
final contact =
|
||||||
|
await FlutterContacts.getContact(picked.id, withProperties: true);
|
||||||
|
if (contact == null) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to load contact details.',
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contact.phones.isEmpty) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'No Phone Number',
|
||||||
|
message: 'Selected contact has no phone number.',
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final indiaPhones = contact.phones.where((p) {
|
||||||
|
final normalized = p.number.replaceAll(RegExp(r'[^0-9+]'), '');
|
||||||
|
return normalized.startsWith('+91') ||
|
||||||
|
RegExp(r'^\d{10}$').hasMatch(normalized);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (indiaPhones.isEmpty) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'No Indian Number',
|
||||||
|
message: 'Selected contact has no Indian (+91) phone number.',
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? selectedPhone;
|
||||||
|
if (indiaPhones.length == 1) {
|
||||||
|
selectedPhone = indiaPhones.first.number;
|
||||||
|
} else {
|
||||||
|
selectedPhone = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Choose an Indian number'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: indiaPhones
|
||||||
|
.map(
|
||||||
|
(p) => ListTile(
|
||||||
|
title: Text(p.number),
|
||||||
|
onTap: () => Navigator.of(ctx).pop(p.number),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (selectedPhone == null) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalizedPhone = selectedPhone.replaceAll(RegExp(r'[^0-9]'), '');
|
||||||
|
final phoneWithoutCountryCode = normalizedPhone.length > 10
|
||||||
|
? normalizedPhone.substring(normalizedPhone.length - 10)
|
||||||
|
: normalizedPhone;
|
||||||
|
|
||||||
|
basicValidator.getController('phone_number')?.text =
|
||||||
|
phoneWithoutCountryCode;
|
||||||
|
update();
|
||||||
|
} catch (e, st) {
|
||||||
|
logSafe('Error fetching contacts',
|
||||||
|
level: LogLevel.error, error: e, stackTrace: st);
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to fetch contacts.',
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
lib/controller/employee/assign_projects_controller.dart
Normal file
145
lib/controller/employee/assign_projects_controller.dart
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
|
import 'package:marco/model/global_project_model.dart';
|
||||||
|
import 'package:marco/model/employees/assigned_projects_model.dart';
|
||||||
|
import 'package:marco/controller/project_controller.dart';
|
||||||
|
|
||||||
|
class AssignProjectController extends GetxController {
|
||||||
|
final String employeeId;
|
||||||
|
final String jobRoleId;
|
||||||
|
|
||||||
|
AssignProjectController({
|
||||||
|
required this.employeeId,
|
||||||
|
required this.jobRoleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ProjectController projectController = Get.put(ProjectController());
|
||||||
|
|
||||||
|
RxBool isLoading = false.obs;
|
||||||
|
RxBool isAssigning = false.obs;
|
||||||
|
|
||||||
|
RxList<String> assignedProjectIds = <String>[].obs;
|
||||||
|
RxList<String> selectedProjects = <String>[].obs;
|
||||||
|
RxList<GlobalProjectModel> allProjects = <GlobalProjectModel>[].obs;
|
||||||
|
RxList<GlobalProjectModel> filteredProjects = <GlobalProjectModel>[].obs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
fetchAllProjectsAndAssignments();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all projects and assigned projects
|
||||||
|
Future<void> fetchAllProjectsAndAssignments() async {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
await projectController.fetchProjects();
|
||||||
|
allProjects.assignAll(projectController.projects);
|
||||||
|
filteredProjects.assignAll(allProjects); // initially show all
|
||||||
|
|
||||||
|
final responseList = await ApiService.getAssignedProjects(employeeId);
|
||||||
|
if (responseList != null) {
|
||||||
|
final assignedProjects =
|
||||||
|
responseList.map((e) => AssignedProject.fromJson(e)).toList();
|
||||||
|
|
||||||
|
assignedProjectIds.assignAll(
|
||||||
|
assignedProjects.map((p) => p.id).toList(),
|
||||||
|
);
|
||||||
|
selectedProjects.assignAll(assignedProjectIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("All Projects: ${allProjects.map((e) => e.id)}");
|
||||||
|
logSafe("Assigned Project IDs: $assignedProjectIds");
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Error fetching projects or assignments: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign selected projects
|
||||||
|
Future<bool> assignProjectsToEmployee() async {
|
||||||
|
if (selectedProjects.isEmpty) {
|
||||||
|
logSafe("No projects selected for assignment.", level: LogLevel.warning);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Map<String, dynamic>> projectPayload =
|
||||||
|
selectedProjects.map((id) {
|
||||||
|
return {"projectId": id, "jobRoleId": jobRoleId, "status": true};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
isAssigning.value = true;
|
||||||
|
try {
|
||||||
|
final success = await ApiService.assignProjects(
|
||||||
|
employeeId: employeeId,
|
||||||
|
projects: projectPayload,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
logSafe("Projects assigned successfully.");
|
||||||
|
assignedProjectIds.assignAll(selectedProjects);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logSafe("Failed to assign projects.", level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Error assigning projects: $e", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isAssigning.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle project selection
|
||||||
|
void toggleProjectSelection(String projectId, bool isSelected) {
|
||||||
|
if (isSelected) {
|
||||||
|
if (!selectedProjects.contains(projectId)) {
|
||||||
|
selectedProjects.add(projectId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedProjects.remove(projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if project is selected
|
||||||
|
bool isProjectSelected(String projectId) {
|
||||||
|
return selectedProjects.contains(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select all / deselect all
|
||||||
|
void toggleSelectAll() {
|
||||||
|
if (areAllSelected()) {
|
||||||
|
selectedProjects.clear();
|
||||||
|
} else {
|
||||||
|
selectedProjects.assignAll(allProjects.map((p) => p.id.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Are all selected?
|
||||||
|
bool areAllSelected() {
|
||||||
|
return selectedProjects.length == allProjects.length &&
|
||||||
|
allProjects.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter projects by search text
|
||||||
|
void filterProjects(String query) {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
filteredProjects.assignAll(allProjects);
|
||||||
|
} else {
|
||||||
|
filteredProjects.assignAll(
|
||||||
|
allProjects
|
||||||
|
.where((p) => p.name.toLowerCase().contains(query.toLowerCase()))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|
||||||
@ -17,16 +17,16 @@ class EmployeesScreenController extends GetxController {
|
|||||||
|
|
||||||
RxBool isLoading = false.obs;
|
RxBool isLoading = false.obs;
|
||||||
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
||||||
Rxn<EmployeeDetailsModel> selectedEmployeeDetails = Rxn<EmployeeDetailsModel>();
|
Rxn<EmployeeDetailsModel> selectedEmployeeDetails =
|
||||||
|
Rxn<EmployeeDetailsModel>();
|
||||||
RxBool isLoadingEmployeeDetails = false.obs;
|
RxBool isLoadingEmployeeDetails = false.obs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
fetchAllProjects();
|
isLoading.value = true;
|
||||||
|
fetchAllProjects().then((_) {
|
||||||
final projectId = Get.find<ProjectController>().selectedProject?.id;
|
final projectId = Get.find<ProjectController>().selectedProject?.id;
|
||||||
|
|
||||||
if (projectId != null) {
|
if (projectId != null) {
|
||||||
selectedProjectId = projectId;
|
selectedProjectId = projectId;
|
||||||
fetchEmployeesByProject(projectId);
|
fetchEmployeesByProject(projectId);
|
||||||
@ -35,6 +35,7 @@ class EmployeesScreenController extends GetxController {
|
|||||||
} else {
|
} else {
|
||||||
clearEmployees();
|
clearEmployees();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchAllProjects() async {
|
Future<void> fetchAllProjects() async {
|
||||||
@ -50,7 +51,8 @@ class EmployeesScreenController extends GetxController {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
onEmpty: () {
|
onEmpty: () {
|
||||||
logSafe("No project data found or API call failed.", level: LogLevel.warning);
|
logSafe("No project data found or API call failed.",
|
||||||
|
level: LogLevel.warning);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -64,11 +66,13 @@ class EmployeesScreenController extends GetxController {
|
|||||||
update(['employee_screen_controller']);
|
update(['employee_screen_controller']);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchAllEmployees() async {
|
Future<void> fetchAllEmployees({String? organizationId}) async {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
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(
|
logSafe(
|
||||||
@ -78,7 +82,10 @@ class EmployeesScreenController extends GetxController {
|
|||||||
},
|
},
|
||||||
onEmpty: () {
|
onEmpty: () {
|
||||||
employees.clear();
|
employees.clear();
|
||||||
logSafe("No Employee data found or API call failed.", level: LogLevel.warning);
|
logSafe(
|
||||||
|
"No Employee data found or API call failed",
|
||||||
|
level: LogLevel.warning,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -86,36 +93,22 @@ class EmployeesScreenController extends GetxController {
|
|||||||
update(['employee_screen_controller']);
|
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.", level: LogLevel.error);
|
if (projectId.isEmpty) return;
|
||||||
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;
|
||||||
@ -131,15 +124,25 @@ class EmployeesScreenController extends GetxController {
|
|||||||
() => ApiService.getEmployeeDetails(employeeId),
|
() => ApiService.getEmployeeDetails(employeeId),
|
||||||
onSuccess: (data) {
|
onSuccess: (data) {
|
||||||
selectedEmployeeDetails.value = EmployeeDetailsModel.fromJson(data);
|
selectedEmployeeDetails.value = EmployeeDetailsModel.fromJson(data);
|
||||||
logSafe("Employee details loaded for $employeeId", level: LogLevel.info, );
|
logSafe(
|
||||||
|
"Employee details loaded for $employeeId",
|
||||||
|
level: LogLevel.info,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onEmpty: () {
|
onEmpty: () {
|
||||||
selectedEmployeeDetails.value = null;
|
selectedEmployeeDetails.value = null;
|
||||||
logSafe("No employee details found for $employeeId", level: LogLevel.warning, );
|
logSafe(
|
||||||
|
"No employee details found for $employeeId",
|
||||||
|
level: LogLevel.warning,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onError: (e) {
|
onError: (e) {
|
||||||
selectedEmployeeDetails.value = null;
|
selectedEmployeeDetails.value = null;
|
||||||
logSafe("Error fetching employee details for $employeeId", level: LogLevel.error, error: e, );
|
logSafe(
|
||||||
|
"Error fetching employee details for $employeeId",
|
||||||
|
level: LogLevel.error,
|
||||||
|
error: e,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
497
lib/controller/expense/add_expense_controller.dart
Normal file
497
lib/controller/expense/add_expense_controller.dart
Normal file
@ -0,0 +1,497 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:geocoding/geocoding.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:mime/mime.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
|
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/model/employees/employee_model.dart';
|
||||||
|
import 'package:marco/model/expense/expense_type_model.dart';
|
||||||
|
import 'package:marco/model/expense/payment_types_model.dart';
|
||||||
|
|
||||||
|
class AddExpenseController extends GetxController {
|
||||||
|
// --- Text Controllers ---
|
||||||
|
final controllers = <TextEditingController>[
|
||||||
|
TextEditingController(), // amount
|
||||||
|
TextEditingController(), // description
|
||||||
|
TextEditingController(), // supplier
|
||||||
|
TextEditingController(), // transactionId
|
||||||
|
TextEditingController(), // gst
|
||||||
|
TextEditingController(), // location
|
||||||
|
TextEditingController(), // transactionDate
|
||||||
|
TextEditingController(), // noOfPersons
|
||||||
|
TextEditingController(), // employeeSearch
|
||||||
|
];
|
||||||
|
|
||||||
|
TextEditingController get amountController => controllers[0];
|
||||||
|
TextEditingController get descriptionController => controllers[1];
|
||||||
|
TextEditingController get supplierController => controllers[2];
|
||||||
|
TextEditingController get transactionIdController => controllers[3];
|
||||||
|
TextEditingController get gstController => controllers[4];
|
||||||
|
TextEditingController get locationController => controllers[5];
|
||||||
|
TextEditingController get transactionDateController => controllers[6];
|
||||||
|
TextEditingController get noOfPersonsController => controllers[7];
|
||||||
|
TextEditingController get employeeSearchController => controllers[8];
|
||||||
|
|
||||||
|
// --- Reactive State ---
|
||||||
|
final isLoading = false.obs;
|
||||||
|
final isSubmitting = false.obs;
|
||||||
|
final isFetchingLocation = false.obs;
|
||||||
|
final isEditMode = false.obs;
|
||||||
|
final isSearchingEmployees = false.obs;
|
||||||
|
|
||||||
|
// --- Dropdown Selections & Data ---
|
||||||
|
final selectedPaymentMode = Rxn<PaymentModeModel>();
|
||||||
|
final selectedExpenseType = Rxn<ExpenseTypeModel>();
|
||||||
|
final selectedPaidBy = Rxn<EmployeeModel>();
|
||||||
|
final selectedProject = ''.obs;
|
||||||
|
final selectedTransactionDate = Rxn<DateTime>();
|
||||||
|
|
||||||
|
final attachments = <File>[].obs;
|
||||||
|
final existingAttachments = <Map<String, dynamic>>[].obs;
|
||||||
|
final globalProjects = <String>[].obs;
|
||||||
|
final projectsMap = <String, String>{}.obs;
|
||||||
|
|
||||||
|
final expenseTypes = <ExpenseTypeModel>[].obs;
|
||||||
|
final paymentModes = <PaymentModeModel>[].obs;
|
||||||
|
final allEmployees = <EmployeeModel>[].obs;
|
||||||
|
final employeeSearchResults = <EmployeeModel>[].obs;
|
||||||
|
|
||||||
|
String? editingExpenseId;
|
||||||
|
|
||||||
|
final expenseController = Get.find<ExpenseController>();
|
||||||
|
final ImagePicker _picker = ImagePicker();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
loadMasterData();
|
||||||
|
employeeSearchController.addListener(
|
||||||
|
() => searchEmployees(employeeSearchController.text),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
for (var c in controllers) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Employee Search ---
|
||||||
|
Future<void> searchEmployees(String query) async {
|
||||||
|
if (query.trim().isEmpty) return employeeSearchResults.clear();
|
||||||
|
isSearchingEmployees.value = true;
|
||||||
|
try {
|
||||||
|
final data = await ApiService.searchEmployeesBasic(
|
||||||
|
searchString: query.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data is List) {
|
||||||
|
employeeSearchResults.assignAll(
|
||||||
|
data
|
||||||
|
.map((e) => EmployeeModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
employeeSearchResults.clear();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("Error searching employees: $e", level: LogLevel.error);
|
||||||
|
employeeSearchResults.clear();
|
||||||
|
} finally {
|
||||||
|
isSearchingEmployees.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Form Population (Edit) ---
|
||||||
|
Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
|
||||||
|
isEditMode.value = true;
|
||||||
|
editingExpenseId = '${data['id']}';
|
||||||
|
|
||||||
|
selectedProject.value = data['projectName'] ?? '';
|
||||||
|
amountController.text = '${data['amount'] ?? ''}';
|
||||||
|
supplierController.text = data['supplerName'] ?? '';
|
||||||
|
descriptionController.text = data['description'] ?? '';
|
||||||
|
transactionIdController.text = data['transactionId'] ?? '';
|
||||||
|
locationController.text = data['location'] ?? '';
|
||||||
|
noOfPersonsController.text = '${data['noOfPersons'] ?? 0}';
|
||||||
|
|
||||||
|
_setTransactionDate(data['transactionDate']);
|
||||||
|
_setDropdowns(data);
|
||||||
|
await _setPaidBy(data);
|
||||||
|
_setAttachments(data['attachments']);
|
||||||
|
|
||||||
|
_logPrefilledData();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setTransactionDate(dynamic dateStr) {
|
||||||
|
if (dateStr == null) {
|
||||||
|
selectedTransactionDate.value = null;
|
||||||
|
transactionDateController.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final parsed = DateTime.parse(dateStr);
|
||||||
|
selectedTransactionDate.value = parsed;
|
||||||
|
transactionDateController.text = DateFormat('dd-MM-yyyy').format(parsed);
|
||||||
|
} catch (_) {
|
||||||
|
selectedTransactionDate.value = null;
|
||||||
|
transactionDateController.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setDropdowns(Map<String, dynamic> data) {
|
||||||
|
selectedExpenseType.value =
|
||||||
|
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
|
||||||
|
selectedPaymentMode.value =
|
||||||
|
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setPaidBy(Map<String, dynamic> data) async {
|
||||||
|
final paidById = '${data['paidById']}';
|
||||||
|
selectedPaidBy.value =
|
||||||
|
allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
|
||||||
|
|
||||||
|
if (selectedPaidBy.value == null && data['paidByFirstName'] != null) {
|
||||||
|
await searchEmployees(
|
||||||
|
'${data['paidByFirstName']} ${data['paidByLastName']}',
|
||||||
|
);
|
||||||
|
selectedPaidBy.value = employeeSearchResults
|
||||||
|
.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setAttachments(dynamic attachmentsData) {
|
||||||
|
existingAttachments.clear();
|
||||||
|
if (attachmentsData is List) {
|
||||||
|
existingAttachments.addAll(
|
||||||
|
List<Map<String, dynamic>>.from(attachmentsData).map(
|
||||||
|
(e) => {...e, 'isActive': true},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _logPrefilledData() {
|
||||||
|
final info = [
|
||||||
|
'ID: $editingExpenseId',
|
||||||
|
'Project: ${selectedProject.value}',
|
||||||
|
'Amount: ${amountController.text}',
|
||||||
|
'Supplier: ${supplierController.text}',
|
||||||
|
'Description: ${descriptionController.text}',
|
||||||
|
'Transaction ID: ${transactionIdController.text}',
|
||||||
|
'Location: ${locationController.text}',
|
||||||
|
'Transaction Date: ${transactionDateController.text}',
|
||||||
|
'No. of Persons: ${noOfPersonsController.text}',
|
||||||
|
'Expense Type: ${selectedExpenseType.value?.name}',
|
||||||
|
'Payment Mode: ${selectedPaymentMode.value?.name}',
|
||||||
|
'Paid By: ${selectedPaidBy.value?.name}',
|
||||||
|
'Attachments: ${attachments.length}',
|
||||||
|
'Existing Attachments: ${existingAttachments.length}',
|
||||||
|
];
|
||||||
|
for (var line in info) {
|
||||||
|
logSafe(line, level: LogLevel.info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pickers ---
|
||||||
|
Future<void> pickTransactionDate(BuildContext context) async {
|
||||||
|
final pickedDate = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: selectedTransactionDate.value ?? DateTime.now(),
|
||||||
|
firstDate: DateTime(DateTime.now().year - 5),
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pickedDate != null) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final finalDateTime = DateTime(
|
||||||
|
pickedDate.year,
|
||||||
|
pickedDate.month,
|
||||||
|
pickedDate.day,
|
||||||
|
now.hour,
|
||||||
|
now.minute,
|
||||||
|
now.second,
|
||||||
|
);
|
||||||
|
selectedTransactionDate.value = finalDateTime;
|
||||||
|
transactionDateController.text =
|
||||||
|
DateFormat('dd MMM yyyy').format(finalDateTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pickAttachments() async {
|
||||||
|
try {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'],
|
||||||
|
allowMultiple: true,
|
||||||
|
);
|
||||||
|
if (result != null) {
|
||||||
|
attachments.addAll(
|
||||||
|
result.paths.whereType<String>().map(File.new),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_errorSnackbar("Attachment error: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeAttachment(File file) => attachments.remove(file);
|
||||||
|
|
||||||
|
Future<void> pickFromCamera() async {
|
||||||
|
try {
|
||||||
|
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
|
||||||
|
if (pickedFile != null) attachments.add(File(pickedFile.path));
|
||||||
|
} catch (e) {
|
||||||
|
_errorSnackbar("Camera error: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Location ---
|
||||||
|
Future<void> fetchCurrentLocation() async {
|
||||||
|
isFetchingLocation.value = true;
|
||||||
|
try {
|
||||||
|
if (!await _ensureLocationPermission()) return;
|
||||||
|
|
||||||
|
final position = await Geolocator.getCurrentPosition();
|
||||||
|
final placemarks =
|
||||||
|
await placemarkFromCoordinates(position.latitude, position.longitude);
|
||||||
|
|
||||||
|
locationController.text = placemarks.isNotEmpty
|
||||||
|
? [
|
||||||
|
placemarks.first.name,
|
||||||
|
placemarks.first.street,
|
||||||
|
placemarks.first.locality,
|
||||||
|
placemarks.first.administrativeArea,
|
||||||
|
placemarks.first.country,
|
||||||
|
].where((e) => e?.isNotEmpty == true).join(", ")
|
||||||
|
: "${position.latitude}, ${position.longitude}";
|
||||||
|
} catch (e) {
|
||||||
|
_errorSnackbar("Location error: $e");
|
||||||
|
} finally {
|
||||||
|
isFetchingLocation.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _ensureLocationPermission() async {
|
||||||
|
var permission = await Geolocator.checkPermission();
|
||||||
|
if (permission == LocationPermission.denied ||
|
||||||
|
permission == LocationPermission.deniedForever) {
|
||||||
|
permission = await Geolocator.requestPermission();
|
||||||
|
if (permission == LocationPermission.denied ||
|
||||||
|
permission == LocationPermission.deniedForever) {
|
||||||
|
_errorSnackbar("Location permission denied.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!await Geolocator.isLocationServiceEnabled()) {
|
||||||
|
_errorSnackbar("Location service disabled.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data Fetching ---
|
||||||
|
Future<void> loadMasterData() async =>
|
||||||
|
Future.wait([fetchMasterData(), fetchGlobalProjects()]);
|
||||||
|
|
||||||
|
Future<void> fetchMasterData() async {
|
||||||
|
try {
|
||||||
|
final types = await ApiService.getMasterExpenseTypes();
|
||||||
|
if (types is List) {
|
||||||
|
expenseTypes.value = types
|
||||||
|
.map((e) => ExpenseTypeModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final modes = await ApiService.getMasterPaymentModes();
|
||||||
|
if (modes is List) {
|
||||||
|
paymentModes.value = modes
|
||||||
|
.map((e) => PaymentModeModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_errorSnackbar("Failed to fetch master data");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchGlobalProjects() async {
|
||||||
|
try {
|
||||||
|
final response = await ApiService.getGlobalProjects();
|
||||||
|
if (response != null) {
|
||||||
|
final names = <String>[];
|
||||||
|
for (var item in response) {
|
||||||
|
final name = item['name']?.toString().trim();
|
||||||
|
final id = item['id']?.toString().trim();
|
||||||
|
if (name != null && id != null) {
|
||||||
|
projectsMap[name] = id;
|
||||||
|
names.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalProjects.assignAll(names);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("Error fetching projects: $e", level: LogLevel.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Submission ---
|
||||||
|
Future<void> submitOrUpdateExpense() async {
|
||||||
|
if (isSubmitting.value) return;
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
final validationMsg = validateForm();
|
||||||
|
if (validationMsg.isNotEmpty) {
|
||||||
|
_errorSnackbar(validationMsg, "Missing Fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final payload = await _buildExpensePayload();
|
||||||
|
final success = await _submitToApi(payload);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await expenseController.fetchExpenses();
|
||||||
|
Get.back();
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Success",
|
||||||
|
message:
|
||||||
|
"Expense ${isEditMode.value ? 'updated' : 'created'} successfully!",
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_errorSnackbar("Operation failed. Try again.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_errorSnackbar("Unexpected error: $e");
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _submitToApi(Map<String, dynamic> payload) async {
|
||||||
|
if (isEditMode.value && editingExpenseId != null) {
|
||||||
|
return ApiService.editExpenseApi(
|
||||||
|
expenseId: editingExpenseId!,
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ApiService.createExpenseApi(
|
||||||
|
projectId: payload['projectId'],
|
||||||
|
expensesTypeId: payload['expensesTypeId'],
|
||||||
|
paymentModeId: payload['paymentModeId'],
|
||||||
|
paidById: payload['paidById'],
|
||||||
|
transactionDate: DateTime.parse(payload['transactionDate']),
|
||||||
|
transactionId: payload['transactionId'],
|
||||||
|
description: payload['description'],
|
||||||
|
location: payload['location'],
|
||||||
|
supplerName: payload['supplerName'],
|
||||||
|
amount: payload['amount'],
|
||||||
|
noOfPersons: payload['noOfPersons'],
|
||||||
|
billAttachments: payload['billAttachments'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> _buildExpensePayload() async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
final existingPayload = isEditMode.value
|
||||||
|
? existingAttachments
|
||||||
|
.map((e) => {
|
||||||
|
"documentId": e['documentId'],
|
||||||
|
"fileName": e['fileName'],
|
||||||
|
"contentType": e['contentType'],
|
||||||
|
"fileSize": 0,
|
||||||
|
"description": "",
|
||||||
|
"url": e['url'],
|
||||||
|
"isActive": e['isActive'] ?? true,
|
||||||
|
"base64Data": "",
|
||||||
|
})
|
||||||
|
.toList()
|
||||||
|
: <Map<String, dynamic>>[];
|
||||||
|
|
||||||
|
final newPayload = await Future.wait(
|
||||||
|
attachments.map((file) async {
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
return {
|
||||||
|
"fileName": file.path.split('/').last,
|
||||||
|
"base64Data": base64Encode(bytes),
|
||||||
|
"contentType":
|
||||||
|
lookupMimeType(file.path) ?? 'application/octet-stream',
|
||||||
|
"fileSize": await file.length(),
|
||||||
|
"description": "",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final type = selectedExpenseType.value!;
|
||||||
|
|
||||||
|
return {
|
||||||
|
if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId,
|
||||||
|
"projectId": projectsMap[selectedProject.value]!,
|
||||||
|
"expensesTypeId": type.id,
|
||||||
|
"paymentModeId": selectedPaymentMode.value!.id,
|
||||||
|
"paidById": selectedPaidBy.value!.id,
|
||||||
|
"transactionDate":
|
||||||
|
(selectedTransactionDate.value ?? now).toUtc().toIso8601String(),
|
||||||
|
"transactionId": transactionIdController.text,
|
||||||
|
"description": descriptionController.text,
|
||||||
|
"location": locationController.text,
|
||||||
|
"supplerName": supplierController.text,
|
||||||
|
"amount": double.parse(amountController.text.trim()),
|
||||||
|
"noOfPersons": type.noOfPersonsRequired == true
|
||||||
|
? int.tryParse(noOfPersonsController.text.trim()) ?? 0
|
||||||
|
: 0,
|
||||||
|
"billAttachments": [
|
||||||
|
...existingPayload,
|
||||||
|
...newPayload,
|
||||||
|
].isEmpty
|
||||||
|
? null
|
||||||
|
: [...existingPayload, ...newPayload],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String validateForm() {
|
||||||
|
final missing = <String>[];
|
||||||
|
|
||||||
|
if (selectedProject.value.isEmpty) missing.add("Project");
|
||||||
|
if (selectedExpenseType.value == null) missing.add("Expense Type");
|
||||||
|
if (selectedPaymentMode.value == null) missing.add("Payment Mode");
|
||||||
|
if (selectedPaidBy.value == null) missing.add("Paid By");
|
||||||
|
if (amountController.text.trim().isEmpty) missing.add("Amount");
|
||||||
|
if (descriptionController.text.trim().isEmpty) missing.add("Description");
|
||||||
|
|
||||||
|
if (selectedTransactionDate.value == null) {
|
||||||
|
missing.add("Transaction Date");
|
||||||
|
} else if (selectedTransactionDate.value!.isAfter(DateTime.now())) {
|
||||||
|
missing.add("Valid Transaction Date");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (double.tryParse(amountController.text.trim()) == null) {
|
||||||
|
missing.add("Valid Amount");
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasActiveExisting =
|
||||||
|
existingAttachments.any((e) => e['isActive'] != false);
|
||||||
|
if (attachments.isEmpty && !hasActiveExisting) {
|
||||||
|
missing.add("Attachment");
|
||||||
|
}
|
||||||
|
|
||||||
|
return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Snackbar Helper ---
|
||||||
|
void _errorSnackbar(String msg, [String title = "Error"]) {
|
||||||
|
showAppSnackbar(title: title, message: msg, type: SnackbarType.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
187
lib/controller/expense/expense_detail_controller.dart
Normal file
187
lib/controller/expense/expense_detail_controller.dart
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
|
import 'package:marco/model/expense/expense_detail_model.dart';
|
||||||
|
import 'package:marco/model/employees/employee_model.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ExpenseDetailController extends GetxController {
|
||||||
|
final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null);
|
||||||
|
final RxBool isLoading = false.obs;
|
||||||
|
final RxString errorMessage = ''.obs;
|
||||||
|
final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null);
|
||||||
|
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
|
||||||
|
final RxList<EmployeeModel> employeeSearchResults = <EmployeeModel>[].obs;
|
||||||
|
late String _expenseId;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
final employeeSearchController = TextEditingController();
|
||||||
|
final isSearchingEmployees = false.obs;
|
||||||
|
|
||||||
|
/// Call this once from the screen (NOT inside build) to initialize
|
||||||
|
void init(String expenseId) {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
_expenseId = expenseId;
|
||||||
|
|
||||||
|
// Use Future.wait to fetch details and employees concurrently
|
||||||
|
Future.wait([
|
||||||
|
fetchExpenseDetails(),
|
||||||
|
fetchAllEmployees(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic method to handle API calls with loading and error states
|
||||||
|
Future<T?> _apiCallWrapper<T>(
|
||||||
|
Future<T?> Function() apiCall, String operationName) async {
|
||||||
|
isLoading.value = true;
|
||||||
|
errorMessage.value = ''; // Clear previous errors
|
||||||
|
|
||||||
|
try {
|
||||||
|
logSafe("Initiating $operationName...");
|
||||||
|
final result = await apiCall();
|
||||||
|
logSafe("$operationName completed successfully.");
|
||||||
|
return result;
|
||||||
|
} catch (e, stack) {
|
||||||
|
errorMessage.value =
|
||||||
|
'An unexpected error occurred during $operationName.';
|
||||||
|
logSafe("Exception in $operationName: $e", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch expense details by stored ID
|
||||||
|
Future<void> fetchExpenseDetails() async {
|
||||||
|
final result = await _apiCallWrapper(
|
||||||
|
() => ApiService.getExpenseDetailsApi(expenseId: _expenseId),
|
||||||
|
"fetch expense details");
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
try {
|
||||||
|
expense.value = ExpenseDetailModel.fromJson(result);
|
||||||
|
logSafe("Expense details loaded successfully: ${expense.value?.id}");
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage.value = 'Failed to parse expense details: $e';
|
||||||
|
logSafe("Parse error in fetchExpenseDetails: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorMessage.value = 'Failed to fetch expense details from server.';
|
||||||
|
logSafe("fetchExpenseDetails failed: null response",
|
||||||
|
level: LogLevel.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This method seems like a utility and might be better placed in a helper or utility class
|
||||||
|
// if it's used across multiple controllers. Keeping it here for now as per original code.
|
||||||
|
List<String> parsePermissionIds(dynamic permissionData) {
|
||||||
|
if (permissionData == null) return [];
|
||||||
|
if (permissionData is List) {
|
||||||
|
return permissionData
|
||||||
|
.map((e) => e.toString().trim())
|
||||||
|
.where((e) => e.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
if (permissionData is String) {
|
||||||
|
final clean = permissionData.replaceAll(RegExp(r'[\[\]]'), '');
|
||||||
|
return clean
|
||||||
|
.split(',')
|
||||||
|
.map((e) => e.trim())
|
||||||
|
.where((e) => e.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> searchEmployees(String query) async {
|
||||||
|
if (query.trim().isEmpty) return employeeSearchResults.clear();
|
||||||
|
isSearchingEmployees.value = true;
|
||||||
|
try {
|
||||||
|
final data =
|
||||||
|
await ApiService.searchEmployeesBasic(searchString: query.trim());
|
||||||
|
employeeSearchResults.assignAll(
|
||||||
|
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("Error searching employees: $e", level: LogLevel.error);
|
||||||
|
employeeSearchResults.clear();
|
||||||
|
} finally {
|
||||||
|
isSearchingEmployees.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all employees
|
||||||
|
Future<void> fetchAllEmployees() async {
|
||||||
|
final response = await _apiCallWrapper(
|
||||||
|
() => ApiService.getAllEmployees(), "fetch all employees");
|
||||||
|
|
||||||
|
if (response != null && response.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e)));
|
||||||
|
logSafe("All Employees fetched: ${allEmployees.length}",
|
||||||
|
level: LogLevel.info);
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage.value = 'Failed to parse employee data: $e';
|
||||||
|
logSafe("Parse error in fetchAllEmployees: $e", level: LogLevel.error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
allEmployees.clear();
|
||||||
|
logSafe("No employees found.", level: LogLevel.warning);
|
||||||
|
}
|
||||||
|
// `update()` is typically not needed for RxList directly unless you have specific GetBuilder/Obx usage that requires it
|
||||||
|
// If you are using Obx widgets, `allEmployees.assignAll` will automatically trigger a rebuild.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update expense with reimbursement info and status
|
||||||
|
Future<bool> updateExpenseStatusWithReimbursement({
|
||||||
|
required String comment,
|
||||||
|
required String reimburseTransactionId,
|
||||||
|
required String reimburseDate,
|
||||||
|
required String reimburseById,
|
||||||
|
required String statusId,
|
||||||
|
}) async {
|
||||||
|
final success = await _apiCallWrapper(
|
||||||
|
() => ApiService.updateExpenseStatusApi(
|
||||||
|
expenseId: _expenseId,
|
||||||
|
statusId: statusId,
|
||||||
|
comment: comment,
|
||||||
|
reimburseTransactionId: reimburseTransactionId,
|
||||||
|
reimburseDate: reimburseDate,
|
||||||
|
reimbursedById: reimburseById,
|
||||||
|
),
|
||||||
|
"submit reimbursement",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success == true) {
|
||||||
|
// Explicitly check for true as _apiCallWrapper returns T?
|
||||||
|
await fetchExpenseDetails(); // Refresh details after successful update
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
errorMessage.value = "Failed to submit reimbursement.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update status for this specific expense
|
||||||
|
Future<bool> updateExpenseStatus(String statusId, {String? comment}) async {
|
||||||
|
final success = await _apiCallWrapper(
|
||||||
|
() => ApiService.updateExpenseStatusApi(
|
||||||
|
expenseId: _expenseId,
|
||||||
|
statusId: statusId,
|
||||||
|
comment: comment,
|
||||||
|
),
|
||||||
|
"update expense status",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success == true) {
|
||||||
|
await fetchExpenseDetails();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
errorMessage.value = "Failed to update expense status.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
357
lib/controller/expense/expense_screen_controller.dart
Normal file
357
lib/controller/expense/expense_screen_controller.dart
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
|
import 'package:marco/model/expense/expense_list_model.dart';
|
||||||
|
import 'package:marco/model/expense/payment_types_model.dart';
|
||||||
|
import 'package:marco/model/expense/expense_type_model.dart';
|
||||||
|
import 'package:marco/model/expense/expense_status_model.dart';
|
||||||
|
import 'package:marco/model/employees/employee_model.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ExpenseController extends GetxController {
|
||||||
|
final RxList<ExpenseModel> expenses = <ExpenseModel>[].obs;
|
||||||
|
final RxBool isLoading = false.obs;
|
||||||
|
final RxString errorMessage = ''.obs;
|
||||||
|
|
||||||
|
// Master data
|
||||||
|
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
|
||||||
|
final RxList<PaymentModeModel> paymentModes = <PaymentModeModel>[].obs;
|
||||||
|
final RxList<ExpenseStatusModel> expenseStatuses = <ExpenseStatusModel>[].obs;
|
||||||
|
final RxList<String> globalProjects = <String>[].obs;
|
||||||
|
final RxMap<String, String> projectsMap = <String, String>{}.obs;
|
||||||
|
RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
|
||||||
|
|
||||||
|
// Persistent Filter States
|
||||||
|
final RxString selectedProject = ''.obs;
|
||||||
|
final RxString selectedStatus = ''.obs;
|
||||||
|
final Rx<DateTime?> startDate = Rx<DateTime?>(null);
|
||||||
|
final Rx<DateTime?> endDate = Rx<DateTime?>(null);
|
||||||
|
final RxList<EmployeeModel> selectedPaidByEmployees = <EmployeeModel>[].obs;
|
||||||
|
final RxList<EmployeeModel> selectedCreatedByEmployees =
|
||||||
|
<EmployeeModel>[].obs;
|
||||||
|
final RxString selectedDateType = 'Transaction Date'.obs;
|
||||||
|
|
||||||
|
final employeeSearchController = TextEditingController();
|
||||||
|
final isSearchingEmployees = false.obs;
|
||||||
|
final employeeSearchResults = <EmployeeModel>[].obs;
|
||||||
|
|
||||||
|
final List<String> dateTypes = [
|
||||||
|
'Transaction Date',
|
||||||
|
'Created At',
|
||||||
|
];
|
||||||
|
|
||||||
|
int _pageSize = 20;
|
||||||
|
int _pageNumber = 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
loadInitialMasterData();
|
||||||
|
fetchAllEmployees();
|
||||||
|
employeeSearchController.addListener(() {
|
||||||
|
searchEmployees(employeeSearchController.text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isFilterApplied {
|
||||||
|
return selectedProject.value.isNotEmpty ||
|
||||||
|
selectedStatus.value.isNotEmpty ||
|
||||||
|
startDate.value != null ||
|
||||||
|
endDate.value != null ||
|
||||||
|
selectedPaidByEmployees.isNotEmpty ||
|
||||||
|
selectedCreatedByEmployees.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load master data
|
||||||
|
Future<void> loadInitialMasterData() async {
|
||||||
|
await fetchGlobalProjects();
|
||||||
|
await fetchMasterData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteExpense(String expenseId) async {
|
||||||
|
try {
|
||||||
|
logSafe("Attempting to delete expense: $expenseId");
|
||||||
|
final success = await ApiService.deleteExpense(expenseId);
|
||||||
|
if (success) {
|
||||||
|
expenses.removeWhere((e) => e.id == expenseId);
|
||||||
|
logSafe("Expense deleted successfully.");
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Deleted",
|
||||||
|
message: "Expense has been deleted successfully.",
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logSafe("Failed to delete expense: $expenseId", level: LogLevel.error);
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Failed",
|
||||||
|
message: "Failed to delete expense.",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception in deleteExpense: $e", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Something went wrong while deleting.",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> searchEmployees(String searchQuery) async {
|
||||||
|
if (searchQuery.trim().isEmpty) {
|
||||||
|
employeeSearchResults.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSearchingEmployees.value = true;
|
||||||
|
try {
|
||||||
|
final results = await ApiService.searchEmployeesBasic(
|
||||||
|
searchString: searchQuery.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (results != null) {
|
||||||
|
employeeSearchResults.assignAll(
|
||||||
|
results.map((e) => EmployeeModel.fromJson(e)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
employeeSearchResults.clear();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("Error searching employees: $e", level: LogLevel.error);
|
||||||
|
employeeSearchResults.clear();
|
||||||
|
} finally {
|
||||||
|
isSearchingEmployees.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch expenses using filters
|
||||||
|
Future<void> fetchExpenses({
|
||||||
|
List<String>? projectIds,
|
||||||
|
List<String>? statusIds,
|
||||||
|
List<String>? createdByIds,
|
||||||
|
List<String>? paidByIds,
|
||||||
|
DateTime? startDate,
|
||||||
|
DateTime? endDate,
|
||||||
|
int pageSize = 20,
|
||||||
|
int pageNumber = 1,
|
||||||
|
}) async {
|
||||||
|
isLoading.value = true;
|
||||||
|
errorMessage.value = '';
|
||||||
|
expenses.clear();
|
||||||
|
_pageSize = pageSize;
|
||||||
|
_pageNumber = pageNumber;
|
||||||
|
|
||||||
|
final Map<String, dynamic> filterMap = {
|
||||||
|
"projectIds": projectIds ??
|
||||||
|
(selectedProject.value.isEmpty
|
||||||
|
? []
|
||||||
|
: [projectsMap[selectedProject.value] ?? '']),
|
||||||
|
"statusIds": statusIds ??
|
||||||
|
(selectedStatus.value.isEmpty ? [] : [selectedStatus.value]),
|
||||||
|
"createdByIds":
|
||||||
|
createdByIds ?? selectedCreatedByEmployees.map((e) => e.id).toList(),
|
||||||
|
"paidByIds":
|
||||||
|
paidByIds ?? selectedPaidByEmployees.map((e) => e.id).toList(),
|
||||||
|
"startDate": (startDate ?? this.startDate.value)?.toIso8601String(),
|
||||||
|
"endDate": (endDate ?? this.endDate.value)?.toIso8601String(),
|
||||||
|
"isTransactionDate": selectedDateType.value == 'Transaction Date',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
logSafe("Fetching expenses with filter: ${jsonEncode(filterMap)}");
|
||||||
|
|
||||||
|
final result = await ApiService.getExpenseListApi(
|
||||||
|
filter: jsonEncode(filterMap),
|
||||||
|
pageSize: _pageSize,
|
||||||
|
pageNumber: _pageNumber,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
try {
|
||||||
|
final expenseResponse = ExpenseResponse.fromJson(result);
|
||||||
|
|
||||||
|
// If the backend returns no data, treat it as empty list
|
||||||
|
if (expenseResponse.data.data.isEmpty) {
|
||||||
|
expenses.clear();
|
||||||
|
errorMessage.value = ''; // no error
|
||||||
|
logSafe("Expense list is empty.");
|
||||||
|
} else {
|
||||||
|
expenses.assignAll(expenseResponse.data.data);
|
||||||
|
logSafe("Expenses loaded: ${expenses.length}");
|
||||||
|
logSafe(
|
||||||
|
"Pagination Info: Page ${expenseResponse.data.currentPage} of ${expenseResponse.data.totalPages} | Total: ${expenseResponse.data.totalEntites}");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage.value = 'Failed to parse expenses: $e';
|
||||||
|
logSafe("Parse error in fetchExpenses: $e", level: LogLevel.error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Only treat as error if this means a network or server failure
|
||||||
|
errorMessage.value = 'Unable to connect to the server.';
|
||||||
|
logSafe("fetchExpenses failed: null response", level: LogLevel.error);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
errorMessage.value = 'An unexpected error occurred.';
|
||||||
|
logSafe("Exception in fetchExpenses: $e", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all filters
|
||||||
|
void clearFilters() {
|
||||||
|
selectedProject.value = '';
|
||||||
|
selectedStatus.value = '';
|
||||||
|
startDate.value = null;
|
||||||
|
endDate.value = null;
|
||||||
|
selectedPaidByEmployees.clear();
|
||||||
|
selectedCreatedByEmployees.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch master data: expense types, payment modes, and expense status
|
||||||
|
Future<void> fetchMasterData() async {
|
||||||
|
try {
|
||||||
|
final expenseTypesData = await ApiService.getMasterExpenseTypes();
|
||||||
|
if (expenseTypesData is List) {
|
||||||
|
expenseTypes.value =
|
||||||
|
expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final paymentModesData = await ApiService.getMasterPaymentModes();
|
||||||
|
if (paymentModesData is List) {
|
||||||
|
paymentModes.value =
|
||||||
|
paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final expenseStatusData = await ApiService.getMasterExpenseStatus();
|
||||||
|
if (expenseStatusData is List) {
|
||||||
|
expenseStatuses.value = expenseStatusData
|
||||||
|
.map((e) => ExpenseStatusModel.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to fetch master data: $e",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch global projects
|
||||||
|
Future<void> fetchGlobalProjects() async {
|
||||||
|
try {
|
||||||
|
final response = await ApiService.getGlobalProjects();
|
||||||
|
if (response != null) {
|
||||||
|
final names = <String>[];
|
||||||
|
for (var item in response) {
|
||||||
|
final name = item['name']?.toString().trim();
|
||||||
|
final id = item['id']?.toString().trim();
|
||||||
|
if (name != null && id != null && name.isNotEmpty) {
|
||||||
|
projectsMap[name] = id;
|
||||||
|
names.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalProjects.assignAll(names);
|
||||||
|
logSafe("Fetched ${names.length} global projects");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("Failed to fetch global projects: $e", level: LogLevel.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all employees
|
||||||
|
Future<void> fetchAllEmployees() async {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
final response = await ApiService.getAllEmployees();
|
||||||
|
if (response != null && response.isNotEmpty) {
|
||||||
|
allEmployees
|
||||||
|
.assignAll(response.map((json) => EmployeeModel.fromJson(json)));
|
||||||
|
logSafe(
|
||||||
|
"All Employees fetched for Manage Bucket: ${allEmployees.length}",
|
||||||
|
level: LogLevel.info,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
allEmployees.clear();
|
||||||
|
logSafe("No employees found for Manage Bucket.",
|
||||||
|
level: LogLevel.warning);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
allEmployees.clear();
|
||||||
|
logSafe("Error fetching employees in Manage Bucket",
|
||||||
|
level: LogLevel.error, error: e);
|
||||||
|
}
|
||||||
|
isLoading.value = false;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadMoreExpenses() async {
|
||||||
|
if (isLoading.value) return;
|
||||||
|
|
||||||
|
_pageNumber += 1;
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
final Map<String, dynamic> filterMap = {
|
||||||
|
"projectIds": selectedProject.value.isEmpty
|
||||||
|
? []
|
||||||
|
: [projectsMap[selectedProject.value] ?? ''],
|
||||||
|
"statusIds": selectedStatus.value.isEmpty ? [] : [selectedStatus.value],
|
||||||
|
"createdByIds": selectedCreatedByEmployees.map((e) => e.id).toList(),
|
||||||
|
"paidByIds": selectedPaidByEmployees.map((e) => e.id).toList(),
|
||||||
|
"startDate": startDate.value?.toIso8601String(),
|
||||||
|
"endDate": endDate.value?.toIso8601String(),
|
||||||
|
"isTransactionDate": selectedDateType.value == 'Transaction Date',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await ApiService.getExpenseListApi(
|
||||||
|
filter: jsonEncode(filterMap),
|
||||||
|
pageSize: _pageSize,
|
||||||
|
pageNumber: _pageNumber,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
final expenseResponse = ExpenseResponse.fromJson(result);
|
||||||
|
expenses.addAll(expenseResponse.data.data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("Error in loadMoreExpenses: $e", level: LogLevel.error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update expense status
|
||||||
|
Future<bool> updateExpenseStatus(String expenseId, String statusId) async {
|
||||||
|
isLoading.value = true;
|
||||||
|
errorMessage.value = '';
|
||||||
|
try {
|
||||||
|
logSafe("Updating status for expense: $expenseId -> $statusId");
|
||||||
|
final success = await ApiService.updateExpenseStatusApi(
|
||||||
|
expenseId: expenseId,
|
||||||
|
statusId: statusId,
|
||||||
|
);
|
||||||
|
if (success) {
|
||||||
|
logSafe("Expense status updated successfully.");
|
||||||
|
await fetchExpenses();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
errorMessage.value = "Failed to update expense status.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
errorMessage.value = 'An unexpected error occurred.';
|
||||||
|
logSafe("Exception in updateExpenseStatus: $e", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,23 +109,43 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> get allowedPermissionIds {
|
||||||
|
final ids = permissions.map((p) => p.id).toList();
|
||||||
|
logSafe("[PermissionController] Allowed Permission IDs: $ids",
|
||||||
|
level: LogLevel.debug);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasAnyPermission(List<String> ids) {
|
||||||
|
logSafe("[PermissionController] Checking if any of these are allowed: $ids",
|
||||||
|
level: LogLevel.debug);
|
||||||
|
final allowed = allowedPermissionIds;
|
||||||
|
final result = ids.any((id) => allowed.contains(id));
|
||||||
|
logSafe("[PermissionController] Permission match result: $result",
|
||||||
|
level: LogLevel.debug);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onClose() {
|
void onClose() {
|
||||||
_refreshTimer?.cancel();
|
_refreshTimer?.cancel();
|
||||||
|
|||||||
@ -1,180 +0,0 @@
|
|||||||
import 'package:get/get.dart';
|
|
||||||
import 'package:marco/helpers/services/app_logger.dart';
|
|
||||||
import 'package:marco/helpers/services/api_service.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_form_validator.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
|
||||||
import 'package:marco/model/project_model.dart';
|
|
||||||
import 'package:marco/model/dailyTaskPlaning/daily_task_planing_model.dart';
|
|
||||||
import 'package:marco/model/employee_model.dart';
|
|
||||||
|
|
||||||
class DailyTaskPlaningController extends GetxController {
|
|
||||||
List<ProjectModel> projects = [];
|
|
||||||
List<EmployeeModel> employees = [];
|
|
||||||
List<TaskPlanningDetailsModel> dailyTasks = [];
|
|
||||||
|
|
||||||
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
|
||||||
RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs;
|
|
||||||
|
|
||||||
MyFormValidator basicValidator = MyFormValidator();
|
|
||||||
List<Map<String, dynamic>> roles = [];
|
|
||||||
|
|
||||||
RxnString selectedRoleId = RxnString();
|
|
||||||
RxBool isLoading = false.obs;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onInit() {
|
|
||||||
super.onInit();
|
|
||||||
fetchRoles();
|
|
||||||
_initializeDefaults();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _initializeDefaults() {
|
|
||||||
fetchProjects();
|
|
||||||
}
|
|
||||||
|
|
||||||
String? formFieldValidator(String? value, {required String fieldType}) {
|
|
||||||
if (value == null || value.trim().isEmpty) {
|
|
||||||
return 'This field is required';
|
|
||||||
}
|
|
||||||
if (fieldType == "target" && int.tryParse(value.trim()) == null) {
|
|
||||||
return 'Please enter a valid number';
|
|
||||||
}
|
|
||||||
if (fieldType == "description" && value.trim().length < 5) {
|
|
||||||
return 'Description must be at least 5 characters';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateSelectedEmployees() {
|
|
||||||
final selected = employees
|
|
||||||
.where((e) => uploadingStates[e.id]?.value == true)
|
|
||||||
.toList();
|
|
||||||
selectedEmployees.value = selected;
|
|
||||||
logSafe("Updated selected employees", level: LogLevel.debug, );
|
|
||||||
}
|
|
||||||
|
|
||||||
void onRoleSelected(String? roleId) {
|
|
||||||
selectedRoleId.value = roleId;
|
|
||||||
logSafe("Role selected", level: LogLevel.info, );
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchRoles() async {
|
|
||||||
logSafe("Fetching roles...", level: LogLevel.info);
|
|
||||||
final result = await ApiService.getRoles();
|
|
||||||
if (result != null) {
|
|
||||||
roles = List<Map<String, dynamic>>.from(result);
|
|
||||||
logSafe("Roles fetched successfully", level: LogLevel.info);
|
|
||||||
update();
|
|
||||||
} else {
|
|
||||||
logSafe("Failed to fetch roles", level: LogLevel.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> assignDailyTask({
|
|
||||||
required String workItemId,
|
|
||||||
required int plannedTask,
|
|
||||||
required String description,
|
|
||||||
required List<String> taskTeam,
|
|
||||||
DateTime? assignmentDate,
|
|
||||||
}) async {
|
|
||||||
logSafe("Starting assign task...", level: LogLevel.info);
|
|
||||||
|
|
||||||
final response = await ApiService.assignDailyTask(
|
|
||||||
workItemId: workItemId,
|
|
||||||
plannedTask: plannedTask,
|
|
||||||
description: description,
|
|
||||||
taskTeam: taskTeam,
|
|
||||||
assignmentDate: assignmentDate,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response == true) {
|
|
||||||
logSafe("Task assigned successfully", level: LogLevel.info);
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Success",
|
|
||||||
message: "Task assigned successfully!",
|
|
||||||
type: SnackbarType.success,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
logSafe("Failed to assign task", level: LogLevel.error);
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Error",
|
|
||||||
message: "Failed to assign task.",
|
|
||||||
type: SnackbarType.error,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchProjects() async {
|
|
||||||
isLoading.value = true;
|
|
||||||
try {
|
|
||||||
final response = await ApiService.getProjects();
|
|
||||||
if (response?.isEmpty ?? true) {
|
|
||||||
logSafe("No project data found or API call failed", level: LogLevel.warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
projects = response!.map((json) => ProjectModel.fromJson(json)).toList();
|
|
||||||
logSafe("Projects fetched: ${projects.length} projects loaded", level: LogLevel.info);
|
|
||||||
update();
|
|
||||||
} catch (e, stack) {
|
|
||||||
logSafe("Error fetching projects", level: LogLevel.error, error: e, stackTrace: stack);
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchTaskData(String? projectId) async {
|
|
||||||
if (projectId == null) {
|
|
||||||
logSafe("Project ID is null", level: LogLevel.warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true;
|
|
||||||
try {
|
|
||||||
final response = await ApiService.getDailyTasksDetails(projectId);
|
|
||||||
final data = response?['data'];
|
|
||||||
if (data != null) {
|
|
||||||
dailyTasks = [TaskPlanningDetailsModel.fromJson(data)];
|
|
||||||
logSafe("Daily task Planning Details fetched", level: LogLevel.info, );
|
|
||||||
} else {
|
|
||||||
logSafe("Data field is null", level: LogLevel.warning);
|
|
||||||
}
|
|
||||||
} catch (e, stack) {
|
|
||||||
logSafe("Error fetching daily task data", level: LogLevel.error, error: e, stackTrace: stack);
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchEmployeesByProject(String? projectId) async {
|
|
||||||
if (projectId == null || projectId.isEmpty) {
|
|
||||||
logSafe("Project ID is required but was null or empty", level: LogLevel.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true;
|
|
||||||
try {
|
|
||||||
final response = await ApiService.getAllEmployeesByProject(projectId);
|
|
||||||
if (response != null && response.isNotEmpty) {
|
|
||||||
employees = response.map((json) => EmployeeModel.fromJson(json)).toList();
|
|
||||||
for (var emp in employees) {
|
|
||||||
uploadingStates[emp.id] = false.obs;
|
|
||||||
}
|
|
||||||
logSafe("Employees fetched: ${employees.length} for project $projectId",
|
|
||||||
level: LogLevel.info, );
|
|
||||||
} else {
|
|
||||||
employees = [];
|
|
||||||
logSafe("No employees found for project $projectId", level: LogLevel.warning, );
|
|
||||||
}
|
|
||||||
} catch (e, stack) {
|
|
||||||
logSafe("Error fetching employees for project $projectId",
|
|
||||||
level: LogLevel.error, error: e, stackTrace: stack, );
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
||||||
198
lib/controller/task_planning/daily_task_controller.dart
Normal file
198
lib/controller/task_planning/daily_task_controller.dart
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
264
lib/controller/task_planning/daily_task_planning_controller.dart
Normal file
264
lib/controller/task_planning/daily_task_planning_controller.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
@ -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 {
|
||||||
66
lib/controller/tenant/all_organization_controller.dart
Normal file
66
lib/controller/tenant/all_organization_controller.dart
Normal 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";
|
||||||
|
}
|
||||||
52
lib/controller/tenant/organization_selection_controller.dart
Normal file
52
lib/controller/tenant/organization_selection_controller.dart
Normal 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";
|
||||||
|
}
|
||||||
43
lib/controller/tenant/service_controller.dart
Normal file
43
lib/controller/tenant/service_controller.dart
Normal 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";
|
||||||
|
}
|
||||||
136
lib/controller/tenant/tenant_selection_controller.dart
Normal file
136
lib/controller/tenant/tenant_selection_controller.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
lib/controller/tenant/tenant_switch_controller.dart
Normal file
106
lib/controller/tenant/tenant_switch_controller.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +1,38 @@
|
|||||||
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 Screen 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 Screen 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";
|
||||||
static const String uploadAttendanceImage = "/attendance/record-image";
|
static const String uploadAttendanceImage = "/attendance/record-image";
|
||||||
|
|
||||||
// Employee Screen API Endpoints
|
// Employee Screen API Endpoints
|
||||||
static const String getAllEmployeesByProject = "/Project/employees/get";
|
static const String getAllEmployeesByProject = "/employee/list";
|
||||||
|
static const String getAllEmployeesByOrganization = "/project/get/task/team";
|
||||||
static const String getAllEmployees = "/employee/list";
|
static const String getAllEmployees = "/employee/list";
|
||||||
|
static const String getEmployeesWithoutPermission = "/employee/basic";
|
||||||
static const String getRoles = "/roles/jobrole";
|
static const String getRoles = "/roles/jobrole";
|
||||||
static const String createEmployee = "/employee/manage";
|
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 getAssignedProjects = "/project/assigned-projects";
|
||||||
|
static const String assignProjects = "/project/assign-projects";
|
||||||
|
|
||||||
// Daily Task Screen API Endpoints
|
// Daily Task Module API Endpoints
|
||||||
static const String getDailyTask = "/task/list";
|
static const String getDailyTask = "/task/list";
|
||||||
static const String reportTask = "/task/report";
|
static const String reportTask = "/task/report";
|
||||||
static const String commentTask = "/task/comment";
|
static const String commentTask = "/task/comment";
|
||||||
@ -31,19 +42,62 @@ class ApiEndpoints {
|
|||||||
static const String approveReportAction = "/task/approve";
|
static const String approveReportAction = "/task/approve";
|
||||||
static const String assignTask = "/project/task";
|
static const String assignTask = "/project/task";
|
||||||
static const String getmasterWorkCategories = "/Master/work-categories";
|
static const String getmasterWorkCategories = "/Master/work-categories";
|
||||||
|
static const String getDailyTaskProjectProgressFilter = "/task/filter";
|
||||||
|
|
||||||
////// Directory Screen API Endpoints
|
////// Directory Module API Endpoints ///////
|
||||||
static const String getDirectoryContacts = "/directory";
|
static const String getDirectoryContacts = "/directory";
|
||||||
static const String getDirectoryBucketList = "/directory/buckets";
|
static const String getDirectoryBucketList = "/directory/buckets";
|
||||||
static const String getDirectoryContactDetail = "/directory/notes";
|
static const String getDirectoryContactDetail = "/directory/notes";
|
||||||
static const String getDirectoryContactCategory = "/master/contact-categories";
|
static const String getDirectoryContactCategory =
|
||||||
|
"/master/contact-categories";
|
||||||
static const String getDirectoryContactTags = "/master/contact-tags";
|
static const String getDirectoryContactTags = "/master/contact-tags";
|
||||||
static const String getDirectoryOrganization = "/directory/organization";
|
static const String getDirectoryOrganization = "/directory/organization";
|
||||||
static const String createContact = "/directory";
|
static const String createContact = "/directory";
|
||||||
static const String updateContact = "/directory";
|
static const String updateContact = "/directory";
|
||||||
|
static const String deleteContact = "/directory";
|
||||||
|
static const String restoreContact = "/directory/note";
|
||||||
static const String getDirectoryNotes = "/directory/notes";
|
static const String getDirectoryNotes = "/directory/notes";
|
||||||
static const String updateDirectoryNotes = "/directory/note";
|
static const String updateDirectoryNotes = "/directory/note";
|
||||||
static const String createBucket = "/directory/bucket";
|
static const String createBucket = "/directory/bucket";
|
||||||
static const String updateBucket = "/directory/bucket";
|
static const String updateBucket = "/directory/bucket";
|
||||||
static const String assignBucket = "/directory/assign-bucket";
|
static const String assignBucket = "/directory/assign-bucket";
|
||||||
|
|
||||||
|
////// Expense Module API Endpoints
|
||||||
|
static const String getExpenseCategories = "/expense/categories";
|
||||||
|
static const String getExpenseList = "/expense/list";
|
||||||
|
static const String getExpenseDetails = "/expense/details";
|
||||||
|
static const String createExpense = "/expense/create";
|
||||||
|
static const String editExpense = "/Expense/edit";
|
||||||
|
static const String getMasterPaymentModes = "/master/payment-modes";
|
||||||
|
static const String getMasterExpenseStatus = "/master/expenses-status";
|
||||||
|
static const String getMasterExpenseTypes = "/master/expenses-types";
|
||||||
|
static const String updateExpenseStatus = "/expense/action";
|
||||||
|
static const String deleteExpense = "/expense/delete";
|
||||||
|
|
||||||
|
////// Dynamic Menu Module API Endpoints
|
||||||
|
static const String getDynamicMenu = "/appmenu/get/menu-mobile";
|
||||||
|
|
||||||
|
///// Document Module API Endpoints
|
||||||
|
static const String getMasterDocumentCategories =
|
||||||
|
"/master/document-category/list";
|
||||||
|
static const String getMasterDocumentTags = "/document/get/tags";
|
||||||
|
static const String getDocumentList = "/document/list";
|
||||||
|
static const String getDocumentDetails = "/document/get/details";
|
||||||
|
static const String uploadDocument = "/document/upload";
|
||||||
|
static const String deleteDocument = "/document/delete";
|
||||||
|
static const String getDocumentFilter = "/document/get/filter";
|
||||||
|
static const String getDocumentTypesByCategory = "/master/document-type/list";
|
||||||
|
static const String getDocumentVersion = "/document/get/version";
|
||||||
|
static const String getDocumentVersions = "/document/list/versions";
|
||||||
|
static const String editDocument = "/document/edit";
|
||||||
|
static const String verifyDocument = "/document/verify";
|
||||||
|
|
||||||
|
/// Logs Module API Endpoints
|
||||||
|
static const String uploadLogs = "/log";
|
||||||
|
|
||||||
|
static const String getAssignedOrganizations =
|
||||||
|
"/project/get/assigned/organization";
|
||||||
|
static const getAllOrganizations = "/organization/list";
|
||||||
|
|
||||||
|
static const String getAssignedServices = "/Project/get/assigned/services";
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,67 +1,30 @@
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:get/get.dart';
|
|
||||||
import 'package:marco/controller/permission_controller.dart';
|
|
||||||
import 'package:marco/controller/project_controller.dart';
|
|
||||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
|
||||||
import 'package:marco/helpers/theme/theme_customizer.dart';
|
|
||||||
import 'package:marco/helpers/theme/app_theme.dart';
|
|
||||||
import 'package:url_strategy/url_strategy.dart';
|
import 'package:url_strategy/url_strategy.dart';
|
||||||
|
import 'package: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: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...");
|
||||||
|
|
||||||
setPathUrlStrategy();
|
await Future.wait([
|
||||||
logSafe("💡 URL strategy set.");
|
_setupUI(),
|
||||||
|
_setupFirebase(),
|
||||||
|
_setupLocalStorage(),
|
||||||
|
]);
|
||||||
|
|
||||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
await _setupDeviceInfo();
|
||||||
statusBarColor: Color.fromARGB(255, 255, 0, 0),
|
await _handleAuthTokens();
|
||||||
statusBarIconBrightness: Brightness.light,
|
await _setupTheme();
|
||||||
));
|
await _setupFirebaseMessaging();
|
||||||
logSafe("💡 System UI overlay style set.");
|
|
||||||
|
|
||||||
await LocalStorage.init();
|
_finalizeAppStyle();
|
||||||
logSafe("💡 Local storage initialized.");
|
|
||||||
|
|
||||||
// If a refresh token is found, try to refresh the JWT token
|
|
||||||
final refreshToken = await LocalStorage.getRefreshToken();
|
|
||||||
if (refreshToken != null && refreshToken.isNotEmpty) {
|
|
||||||
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
|
|
||||||
final success = await AuthService.refreshToken();
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
logSafe("⚠️ Refresh token invalid or expired. Skipping controller injection.");
|
|
||||||
// Optionally, clear tokens and force logout here if needed
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logSafe("❌ No refresh token found. Skipping refresh.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await ThemeCustomizer.init();
|
|
||||||
logSafe("💡 Theme customizer initialized.");
|
|
||||||
|
|
||||||
final token = LocalStorage.getString('jwt_token');
|
|
||||||
if (token != null && token.isNotEmpty) {
|
|
||||||
if (!Get.isRegistered<PermissionController>()) {
|
|
||||||
Get.put(PermissionController());
|
|
||||||
logSafe("💡 PermissionController injected.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Get.isRegistered<ProjectController>()) {
|
|
||||||
Get.put(ProjectController(), permanent: true);
|
|
||||||
logSafe("💡 ProjectController injected as permanent.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load data into controllers if required
|
|
||||||
await Get.find<PermissionController>().loadData(token);
|
|
||||||
await Get.find<ProjectController>().fetchProjects();
|
|
||||||
} else {
|
|
||||||
logSafe("⚠️ No valid JWT token found. Skipping controller initialization.");
|
|
||||||
}
|
|
||||||
|
|
||||||
AppStyle.init();
|
|
||||||
logSafe("💡 AppStyle initialized.");
|
|
||||||
|
|
||||||
logSafe("✅ App initialization completed successfully.");
|
logSafe("✅ App initialization completed successfully.");
|
||||||
} catch (e, stacktrace) {
|
} catch (e, stacktrace) {
|
||||||
@ -74,3 +37,57 @@ Future<void> initializeApp() async {
|
|||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleAuthTokens() async {
|
||||||
|
final refreshToken = await LocalStorage.getRefreshToken();
|
||||||
|
if (refreshToken?.isNotEmpty ?? false) {
|
||||||
|
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
|
||||||
|
final success = await AuthService.refreshToken();
|
||||||
|
if (!success) {
|
||||||
|
logSafe("⚠️ Refresh token invalid or expired. User must login again.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logSafe("❌ No refresh token found. Skipping refresh.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setupUI() async {
|
||||||
|
setPathUrlStrategy();
|
||||||
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
|
logSafe("💡 UI setup completed with default system behavior.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setupFirebase() async {
|
||||||
|
await Firebase.initializeApp();
|
||||||
|
logSafe("💡 Firebase initialized.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setupLocalStorage() async {
|
||||||
|
if (!LocalStorage.isInitialized) {
|
||||||
|
await LocalStorage.init();
|
||||||
|
logSafe("💡 Local storage initialized.");
|
||||||
|
} else {
|
||||||
|
logSafe("ℹ️ Local storage already initialized, skipping.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setupDeviceInfo() async {
|
||||||
|
final deviceInfoService = DeviceInfoService();
|
||||||
|
await deviceInfoService.init();
|
||||||
|
logSafe("📱 Device Info: ${deviceInfoService.deviceData}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setupTheme() async {
|
||||||
|
await ThemeCustomizer.init();
|
||||||
|
logSafe("💡 Theme customizer initialized.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setupFirebaseMessaging() async {
|
||||||
|
await FirebaseNotificationService().initialize();
|
||||||
|
logSafe("💡 Firebase Messaging initialized.");
|
||||||
|
}
|
||||||
|
|
||||||
|
void _finalizeAppStyle() {
|
||||||
|
AppStyle.init();
|
||||||
|
logSafe("💡 AppStyle initialized.");
|
||||||
|
}
|
||||||
|
|||||||
@ -1,21 +1,42 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
|
||||||
/// Global logger instance
|
/// Global logger instance
|
||||||
late final Logger appLogger;
|
Logger? _appLogger;
|
||||||
|
late final FileLogOutput _fileLogOutput;
|
||||||
|
|
||||||
/// Log file output handler
|
/// Store logs temporarily for API posting
|
||||||
late final FileLogOutput fileLogOutput;
|
final List<Map<String, dynamic>> _logBuffer = [];
|
||||||
|
|
||||||
/// Initialize logging (call once in `main()`)
|
/// Lock flag to prevent concurrent posting
|
||||||
|
bool _isPosting = false;
|
||||||
|
|
||||||
|
/// Flag to allow API posting only after login
|
||||||
|
bool _canPostLogs = false;
|
||||||
|
|
||||||
|
/// Maximum number of logs before triggering API post
|
||||||
|
const int _maxLogsBeforePost = 100;
|
||||||
|
|
||||||
|
/// Maximum logs in memory buffer
|
||||||
|
const int _maxBufferSize = 500;
|
||||||
|
|
||||||
|
/// Enum → logger level mapping
|
||||||
|
const _levelMap = {
|
||||||
|
LogLevel.debug: Level.debug,
|
||||||
|
LogLevel.info: Level.info,
|
||||||
|
LogLevel.warning: Level.warning,
|
||||||
|
LogLevel.error: Level.error,
|
||||||
|
LogLevel.verbose: Level.verbose,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Initialize logging
|
||||||
Future<void> initLogging() async {
|
Future<void> initLogging() async {
|
||||||
await requestStoragePermission();
|
_fileLogOutput = FileLogOutput();
|
||||||
|
|
||||||
fileLogOutput = FileLogOutput();
|
_appLogger = Logger(
|
||||||
|
|
||||||
appLogger = Logger(
|
|
||||||
printer: PrettyPrinter(
|
printer: PrettyPrinter(
|
||||||
methodCount: 0,
|
methodCount: 0,
|
||||||
printTime: true,
|
printTime: true,
|
||||||
@ -23,19 +44,17 @@ Future<void> initLogging() async {
|
|||||||
printEmojis: true,
|
printEmojis: true,
|
||||||
),
|
),
|
||||||
output: MultiOutput([
|
output: MultiOutput([
|
||||||
ConsoleOutput(), // ✅ Console will use the top-level PrettyPrinter
|
ConsoleOutput(),
|
||||||
fileLogOutput, // ✅ File will still use the SimpleFileLogPrinter
|
_fileLogOutput,
|
||||||
]),
|
]),
|
||||||
level: Level.debug,
|
level: Level.debug,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request storage permission (for Android 11+)
|
/// Enable API posting after login
|
||||||
Future<void> requestStoragePermission() async {
|
void enableRemoteLogging() {
|
||||||
final status = await Permission.manageExternalStorage.status;
|
_canPostLogs = true;
|
||||||
if (!status.isGranted) {
|
_postBufferedLogs(); // flush logs if any
|
||||||
await Permission.manageExternalStorage.request();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Safe logger wrapper
|
/// Safe logger wrapper
|
||||||
@ -46,35 +65,68 @@ void logSafe(
|
|||||||
StackTrace? stackTrace,
|
StackTrace? stackTrace,
|
||||||
bool sensitive = false,
|
bool sensitive = false,
|
||||||
}) {
|
}) {
|
||||||
if (sensitive) return;
|
if (sensitive || _appLogger == null) return;
|
||||||
|
|
||||||
switch (level) {
|
final loggerLevel = _levelMap[level] ?? Level.info;
|
||||||
case LogLevel.debug:
|
_appLogger!.log(loggerLevel, message, error: error, stackTrace: stackTrace);
|
||||||
appLogger.d(message, error: error, stackTrace: stackTrace);
|
|
||||||
break;
|
// Buffer logs for API posting
|
||||||
case LogLevel.warning:
|
_logBuffer.add({
|
||||||
appLogger.w(message, error: error, stackTrace: stackTrace);
|
"logLevel": level.name,
|
||||||
break;
|
"message": message,
|
||||||
case LogLevel.error:
|
"timeStamp": DateTime.now().toUtc().toIso8601String(),
|
||||||
appLogger.e(message, error: error, stackTrace: stackTrace);
|
"ipAddress": "this is test IP", // TODO: real IP
|
||||||
break;
|
"userAgent": "FlutterApp/1.0", // TODO: device_info_plus
|
||||||
case LogLevel.verbose:
|
"details": error?.toString() ?? stackTrace?.toString(),
|
||||||
appLogger.v(message, error: error, stackTrace: stackTrace);
|
});
|
||||||
break;
|
|
||||||
default:
|
if (_logBuffer.length >= _maxLogsBeforePost) {
|
||||||
appLogger.i(message, error: error, stackTrace: stackTrace);
|
_postBufferedLogs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Custom log output that writes to a local `.txt` file
|
/// Post buffered logs to API
|
||||||
|
Future<void> _postBufferedLogs() async {
|
||||||
|
if (!_canPostLogs) return; // 🚫 skip if not logged in
|
||||||
|
if (_isPosting || _logBuffer.isEmpty) return;
|
||||||
|
|
||||||
|
_isPosting = true;
|
||||||
|
final logsToSend = List<Map<String, dynamic>>.from(_logBuffer);
|
||||||
|
_logBuffer.clear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final success = await ApiService.postLogsApi(logsToSend);
|
||||||
|
if (!success) {
|
||||||
|
_reinsertLogs(logsToSend, reason: "API call returned false");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_reinsertLogs(logsToSend, reason: "API exception: $e");
|
||||||
|
} finally {
|
||||||
|
_isPosting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reinsert logs into buffer if posting fails
|
||||||
|
void _reinsertLogs(List<Map<String, dynamic>> logs, {required String reason}) {
|
||||||
|
_appLogger?.w("Failed to post logs, re-queuing. Reason: $reason");
|
||||||
|
|
||||||
|
if (_logBuffer.length + logs.length > _maxBufferSize) {
|
||||||
|
_appLogger?.e("Buffer full. Dropping ${logs.length} logs to prevent crash.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logBuffer.insertAll(0, logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File-based log output (safe storage)
|
||||||
class FileLogOutput extends LogOutput {
|
class FileLogOutput extends LogOutput {
|
||||||
File? _logFile;
|
File? _logFile;
|
||||||
|
|
||||||
/// Initialize log file in Downloads/marco_logs/log_YYYY-MM-DD.txt
|
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
if (_logFile != null) return;
|
if (_logFile != null) return;
|
||||||
|
|
||||||
final directory = Directory('/storage/emulated/0/Download/marco_logs');
|
final baseDir = await getExternalStorageDirectory();
|
||||||
|
final directory = Directory('${baseDir!.path}/marco_logs');
|
||||||
if (!await directory.exists()) {
|
if (!await directory.exists()) {
|
||||||
await directory.create(recursive: true);
|
await directory.create(recursive: true);
|
||||||
}
|
}
|
||||||
@ -93,7 +145,6 @@ class FileLogOutput extends LogOutput {
|
|||||||
@override
|
@override
|
||||||
void output(OutputEvent event) async {
|
void output(OutputEvent event) async {
|
||||||
await _init();
|
await _init();
|
||||||
|
|
||||||
if (event.lines.isEmpty) return;
|
if (event.lines.isEmpty) return;
|
||||||
|
|
||||||
final logMessage = event.lines.join('\n') + '\n';
|
final logMessage = event.lines.join('\n') + '\n';
|
||||||
@ -119,7 +170,6 @@ class FileLogOutput extends LogOutput {
|
|||||||
return _logFile!.readAsString();
|
return _logFile!.readAsString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete logs older than 3 days
|
|
||||||
Future<void> _cleanOldLogs(Directory directory) async {
|
Future<void> _cleanOldLogs(Directory directory) async {
|
||||||
final files = directory.listSync();
|
final files = directory.listSync();
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
@ -135,22 +185,5 @@ class FileLogOutput extends LogOutput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A simple, readable log printer for file output
|
/// Custom log levels
|
||||||
class SimpleFileLogPrinter extends LogPrinter {
|
|
||||||
@override
|
|
||||||
List<String> log(LogEvent event) {
|
|
||||||
final message = event.message.toString();
|
|
||||||
|
|
||||||
if (message.contains('[SENSITIVE]')) return [];
|
|
||||||
|
|
||||||
final timestamp = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
|
|
||||||
final level = event.level.name.toUpperCase();
|
|
||||||
final error = event.error != null ? ' | ERROR: ${event.error}' : '';
|
|
||||||
final stack =
|
|
||||||
event.stackTrace != null ? '\nSTACKTRACE:\n${event.stackTrace}' : '';
|
|
||||||
return ['[$timestamp] [$level] $message$error$stack'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Optional log level enum for better type safety
|
|
||||||
enum LogLevel { debug, info, warning, error, verbose }
|
enum LogLevel { debug, info, warning, error, verbose }
|
||||||
|
|||||||
@ -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,277 +11,282 @@ class AuthService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
static bool isLoggedIn = false;
|
static bool isLoggedIn = false;
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
/// Login with email and password
|
/* Logout API */
|
||||||
static Future<Map<String, String>?> loginUser(Map<String, dynamic> data) async {
|
/* -------------------------------------------------------------------------- */
|
||||||
|
static Future<bool> logoutApi(String refreshToken, String fcmToken) async {
|
||||||
try {
|
try {
|
||||||
logSafe("Attempting login...");
|
final body = {
|
||||||
final response = await http.post(
|
"refreshToken": refreshToken,
|
||||||
Uri.parse("$_baseUrl/auth/login-mobile"),
|
"fcmToken": fcmToken,
|
||||||
headers: _headers,
|
};
|
||||||
body: jsonEncode(data),
|
|
||||||
);
|
|
||||||
|
|
||||||
final responseData = jsonDecode(response.body);
|
final response = await _post("/auth/logout", body);
|
||||||
if (response.statusCode == 200 && responseData['data'] != null) {
|
|
||||||
|
if (response != null && response['statusCode'] == 200) {
|
||||||
|
logSafe("✅ Logout API successful");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("⚠️ Logout API failed: ${response?['message']}",
|
||||||
|
level: LogLevel.warning);
|
||||||
|
return false;
|
||||||
|
} catch (e, st) {
|
||||||
|
_handleError("Logout API error", e, st);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Public Methods */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
static Future<bool> registerDeviceToken(String fcmToken) async {
|
||||||
|
final token = await LocalStorage.getJwtToken();
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
logSafe("❌ Cannot register device token: missing JWT token",
|
||||||
|
level: LogLevel.warning);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = {"fcmToken": fcmToken};
|
||||||
|
final headers = {
|
||||||
|
..._headers,
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
};
|
||||||
|
final endpoint = "$_baseUrl/auth/set/device-token";
|
||||||
|
|
||||||
|
// 🔹 Log request details
|
||||||
|
logSafe("📡 Device Token API Request");
|
||||||
|
logSafe("➡️ Endpoint: $endpoint");
|
||||||
|
logSafe("➡️ Headers: ${jsonEncode(headers)}");
|
||||||
|
logSafe("➡️ Payload: ${jsonEncode(body)}");
|
||||||
|
|
||||||
|
final data = await _post("/auth/set/device-token", body, authToken: token);
|
||||||
|
|
||||||
|
if (data != null && data['success'] == true) {
|
||||||
|
logSafe("✅ Device token registered successfully.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
logSafe("⚠️ Failed to register device token: ${data?['message']}",
|
||||||
|
level: LogLevel.warning);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, String>?> loginUser(
|
||||||
|
Map<String, dynamic> data) async {
|
||||||
|
logSafe("Attempting login...");
|
||||||
|
logSafe("Login payload (raw): $data");
|
||||||
|
logSafe("Login payload (JSON): ${jsonEncode(data)}");
|
||||||
|
|
||||||
|
final responseData = await _post("/auth/app/login", data);
|
||||||
|
if (responseData == null)
|
||||||
|
return {"error": "Network error. Please check your connection."};
|
||||||
|
|
||||||
|
if (responseData['data'] != null) {
|
||||||
await _handleLoginSuccess(responseData['data']);
|
await _handleLoginSuccess(responseData['data']);
|
||||||
return null;
|
return null;
|
||||||
} else if (response.statusCode == 401) {
|
}
|
||||||
logSafe("Invalid login credentials.", level: LogLevel.warning);
|
if (responseData['statusCode'] == 401) {
|
||||||
return {"password": "Invalid email or password"};
|
return {"password": "Invalid email or password"};
|
||||||
} else {
|
}
|
||||||
logSafe("Login error: ${responseData['message']}", level: LogLevel.warning);
|
|
||||||
return {"error": responseData['message'] ?? "Unexpected error occurred"};
|
return {"error": responseData['message'] ?? "Unexpected error occurred"};
|
||||||
}
|
}
|
||||||
} catch (e, stacktrace) {
|
|
||||||
logSafe("Login exception", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
|
||||||
return {"error": "Network error. Please check your connection."};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refresh JWT token
|
|
||||||
static Future<bool> refreshToken() async {
|
static Future<bool> refreshToken() async {
|
||||||
final accessToken = await LocalStorage.getJwtToken();
|
final accessToken = LocalStorage.getJwtToken();
|
||||||
final refreshToken = await LocalStorage.getRefreshToken();
|
final refreshToken = LocalStorage.getRefreshToken();
|
||||||
|
|
||||||
if (accessToken == null || refreshToken == null || accessToken.isEmpty || refreshToken.isEmpty) {
|
if ([accessToken, refreshToken].any((t) => t == null || t.isEmpty)) {
|
||||||
logSafe("Missing access or refresh token.", level: LogLevel.warning);
|
logSafe("Missing access or refresh token.", level: LogLevel.warning);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final requestBody = {
|
final body = {"token": accessToken, "refreshToken": refreshToken};
|
||||||
"token": accessToken,
|
final data = await _post("/auth/refresh-token", body);
|
||||||
"refreshToken": refreshToken,
|
if (data != null && data['success'] == true) {
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
logSafe("Refreshing token...");
|
|
||||||
final response = await http.post(
|
|
||||||
Uri.parse("$_baseUrl/auth/refresh-token"),
|
|
||||||
headers: _headers,
|
|
||||||
body: jsonEncode(requestBody),
|
|
||||||
);
|
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
|
||||||
if (response.statusCode == 200 && data['success'] == true) {
|
|
||||||
await LocalStorage.setJwtToken(data['data']['token']);
|
await LocalStorage.setJwtToken(data['data']['token']);
|
||||||
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
|
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
|
||||||
await LocalStorage.setLoggedInUser(true);
|
await LocalStorage.setLoggedInUser(true);
|
||||||
logSafe("Token refreshed successfully.");
|
logSafe("Token refreshed successfully.");
|
||||||
|
|
||||||
|
// 🔹 Retry FCM token registration after token refresh
|
||||||
|
final newFcmToken = LocalStorage.getFcmToken();
|
||||||
|
if (newFcmToken?.isNotEmpty ?? false) {
|
||||||
|
final success = await registerDeviceToken(newFcmToken!);
|
||||||
|
logSafe(
|
||||||
|
success
|
||||||
|
? "✅ FCM token re-registered after JWT refresh."
|
||||||
|
: "⚠️ Failed to register FCM token after JWT refresh.",
|
||||||
|
level: success ? LogLevel.info : LogLevel.warning);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
}
|
||||||
logSafe("Refresh token failed: ${data['message']}", level: LogLevel.warning);
|
logSafe("Refresh token failed: ${data?['message']}",
|
||||||
|
level: LogLevel.warning);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (e, stacktrace) {
|
|
||||||
logSafe("Token refresh exception", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forgot password
|
static Future<Map<String, String>?> forgotPassword(String email) =>
|
||||||
static Future<Map<String, String>?> forgotPassword(String email) async {
|
_wrapErrorHandling(() => _post("/auth/forgot-password", {"email": email}),
|
||||||
try {
|
successCondition: (data) => data['success'] == true,
|
||||||
logSafe("Forgot password requested.");
|
defaultError: "Failed to send reset link.");
|
||||||
final response = await http.post(
|
|
||||||
Uri.parse("$_baseUrl/auth/forgot-password"),
|
|
||||||
headers: _headers,
|
|
||||||
body: jsonEncode({"email": email}),
|
|
||||||
);
|
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
static Future<Map<String, String>?> requestDemo(
|
||||||
if (response.statusCode == 200 && data['success'] == true) return null;
|
Map<String, dynamic> demoData) =>
|
||||||
return {"error": data['message'] ?? "Failed to send reset link."};
|
_wrapErrorHandling(() => _post("/market/inquiry", demoData),
|
||||||
} catch (e, stacktrace) {
|
successCondition: (data) => data['success'] == true,
|
||||||
logSafe("Forgot password error", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
defaultError: "Failed to submit demo request.");
|
||||||
return {"error": "Network error. Please check your connection."};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request demo
|
|
||||||
static Future<Map<String, String>?> requestDemo(Map<String, dynamic> demoData) async {
|
|
||||||
try {
|
|
||||||
logSafe("Submitting demo request...");
|
|
||||||
final response = await http.post(
|
|
||||||
Uri.parse("$_baseUrl/market/inquiry"),
|
|
||||||
headers: _headers,
|
|
||||||
body: jsonEncode(demoData),
|
|
||||||
);
|
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
|
||||||
if (response.statusCode == 200 && data['success'] == true) return null;
|
|
||||||
return {"error": data['message'] ?? "Failed to submit demo request."};
|
|
||||||
} catch (e, stacktrace) {
|
|
||||||
logSafe("Request demo error", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
|
||||||
return {"error": "Network error. Please check your connection."};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get list of industries
|
|
||||||
static Future<List<Map<String, dynamic>>?> getIndustries() async {
|
static Future<List<Map<String, dynamic>>?> getIndustries() async {
|
||||||
try {
|
final data = await _get("/market/industries");
|
||||||
logSafe("Fetching industries list...");
|
if (data != null && data['success'] == true) {
|
||||||
final response = await http.get(
|
|
||||||
Uri.parse("$_baseUrl/market/industries"),
|
|
||||||
headers: _headers,
|
|
||||||
);
|
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
|
||||||
if (response.statusCode == 200 && data['success'] == true) {
|
|
||||||
return List<Map<String, dynamic>>.from(data['data']);
|
return List<Map<String, dynamic>>.from(data['data']);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (e, stacktrace) {
|
|
||||||
logSafe("Get industries error", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate MPIN
|
|
||||||
static Future<Map<String, String>?> generateMpin({
|
static Future<Map<String, String>?> generateMpin({
|
||||||
required String employeeId,
|
required String employeeId,
|
||||||
required String mpin,
|
required String mpin,
|
||||||
}) async {
|
}) =>
|
||||||
final token = await LocalStorage.getJwtToken();
|
_wrapErrorHandling(
|
||||||
|
() async {
|
||||||
try {
|
final token = LocalStorage.getJwtToken();
|
||||||
logSafe("Generating MPIN...");
|
return _post(
|
||||||
final response = await http.post(
|
"/auth/generate-mpin",
|
||||||
Uri.parse("$_baseUrl/auth/generate-mpin"),
|
{"employeeId": employeeId, "mpin": mpin},
|
||||||
headers: {
|
authToken: token,
|
||||||
..._headers,
|
);
|
||||||
if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token',
|
|
||||||
},
|
},
|
||||||
body: jsonEncode({"employeeId": employeeId, "mpin": mpin}),
|
successCondition: (data) => data['success'] == true,
|
||||||
|
defaultError: "Failed to generate MPIN.",
|
||||||
);
|
);
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
|
||||||
if (response.statusCode == 200 && data['success'] == true) return null;
|
|
||||||
return {"error": data['message'] ?? "Failed to generate MPIN."};
|
|
||||||
} catch (e, stacktrace) {
|
|
||||||
logSafe("Generate MPIN error", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
|
||||||
return {"error": "Network error. Please check your connection."};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify MPIN
|
|
||||||
static Future<Map<String, String>?> verifyMpin({
|
static Future<Map<String, String>?> verifyMpin({
|
||||||
required String mpin,
|
required String mpin,
|
||||||
required String mpinToken,
|
required String mpinToken,
|
||||||
}) async {
|
required String fcmToken,
|
||||||
|
}) =>
|
||||||
|
_wrapErrorHandling(
|
||||||
|
() async {
|
||||||
final employeeInfo = LocalStorage.getEmployeeInfo();
|
final employeeInfo = LocalStorage.getEmployeeInfo();
|
||||||
if (employeeInfo == null) return {"error": "Employee info not found."};
|
if (employeeInfo == null) return null;
|
||||||
|
|
||||||
final token = await LocalStorage.getJwtToken();
|
final token = await LocalStorage.getJwtToken();
|
||||||
|
return _post(
|
||||||
try {
|
"/auth/login-mpin",
|
||||||
logSafe("Verifying MPIN...");
|
{
|
||||||
final response = await http.post(
|
|
||||||
Uri.parse("$_baseUrl/auth/login-mpin"),
|
|
||||||
headers: {
|
|
||||||
..._headers,
|
|
||||||
if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token',
|
|
||||||
},
|
|
||||||
body: jsonEncode({
|
|
||||||
"employeeId": employeeInfo.id,
|
"employeeId": employeeInfo.id,
|
||||||
"mpin": mpin,
|
"mpin": mpin,
|
||||||
"mpinToken": mpinToken,
|
"mpinToken": mpinToken,
|
||||||
}),
|
"fcmToken": fcmToken,
|
||||||
|
},
|
||||||
|
authToken: token,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
successCondition: (data) => data['success'] == true,
|
||||||
|
defaultError: "MPIN verification failed.",
|
||||||
);
|
);
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
static Future<Map<String, String>?> generateOtp(String email) =>
|
||||||
if (response.statusCode == 200 && data['success'] == true) return null;
|
_wrapErrorHandling(() => _post("/auth/send-otp", {"email": email}),
|
||||||
return {"error": data['message'] ?? "MPIN verification failed."};
|
successCondition: (data) => data['success'] == true,
|
||||||
} catch (e, stacktrace) {
|
defaultError: "Failed to generate OTP.");
|
||||||
logSafe("Verify MPIN error", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
|
||||||
return {"error": "Network error. Please check your connection."};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate OTP
|
|
||||||
static Future<Map<String, String>?> generateOtp(String email) async {
|
|
||||||
try {
|
|
||||||
logSafe("Generating OTP for email...");
|
|
||||||
final response = await http.post(
|
|
||||||
Uri.parse("$_baseUrl/auth/send-otp"),
|
|
||||||
headers: _headers,
|
|
||||||
body: jsonEncode({"email": email}),
|
|
||||||
);
|
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
|
||||||
if (response.statusCode == 200 && data['success'] == true) return null;
|
|
||||||
return {"error": data['message'] ?? "Failed to generate OTP."};
|
|
||||||
} catch (e, stacktrace) {
|
|
||||||
logSafe("Generate OTP error", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
|
||||||
return {"error": "Network error. Please check your connection."};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify OTP and login
|
|
||||||
static Future<Map<String, String>?> verifyOtp({
|
static Future<Map<String, String>?> verifyOtp({
|
||||||
required String email,
|
required String email,
|
||||||
required String otp,
|
required String otp,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
final data = await _post("/auth/login-otp", {"email": email, "otp": otp});
|
||||||
logSafe("Verifying OTP...");
|
if (data != null && data['data'] != null) {
|
||||||
final response = await http.post(
|
|
||||||
Uri.parse("$_baseUrl/auth/login-otp"),
|
|
||||||
headers: _headers,
|
|
||||||
body: jsonEncode({"email": email, "otp": otp}),
|
|
||||||
);
|
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
|
||||||
if (response.statusCode == 200 && data['data'] != null) {
|
|
||||||
await _handleLoginSuccess(data['data']);
|
await _handleLoginSuccess(data['data']);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {"error": data['message'] ?? "OTP verification failed."};
|
return {"error": data?['message'] ?? "OTP verification failed."};
|
||||||
} catch (e, stacktrace) {
|
}
|
||||||
logSafe("Verify OTP error", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
|
||||||
return {"error": "Network error. Please check your connection."};
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Private Utilities */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>?> _post(
|
||||||
|
String path,
|
||||||
|
Map<String, dynamic> body, {
|
||||||
|
String? authToken,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final headers = {
|
||||||
|
..._headers,
|
||||||
|
if (authToken?.isNotEmpty ?? false)
|
||||||
|
'Authorization': 'Bearer $authToken',
|
||||||
|
};
|
||||||
|
final response = await http.post(Uri.parse("$_baseUrl$path"),
|
||||||
|
headers: headers, body: jsonEncode(body));
|
||||||
|
return {
|
||||||
|
...jsonDecode(response.body),
|
||||||
|
"statusCode": response.statusCode,
|
||||||
|
};
|
||||||
|
} catch (e, st) {
|
||||||
|
_handleError("$path POST error", e, st);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle login success flow
|
static Future<Map<String, dynamic>?> _get(
|
||||||
|
String path, {
|
||||||
|
String? authToken,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final headers = {
|
||||||
|
..._headers,
|
||||||
|
if (authToken?.isNotEmpty ?? false)
|
||||||
|
'Authorization': 'Bearer $authToken',
|
||||||
|
};
|
||||||
|
final response =
|
||||||
|
await http.get(Uri.parse("$_baseUrl$path"), headers: headers);
|
||||||
|
return {
|
||||||
|
...jsonDecode(response.body),
|
||||||
|
"statusCode": response.statusCode,
|
||||||
|
};
|
||||||
|
} catch (e, st) {
|
||||||
|
_handleError("$path GET error", e, st);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, String>?> _wrapErrorHandling(
|
||||||
|
Future<Map<String, dynamic>?> Function() request, {
|
||||||
|
required bool Function(Map<String, dynamic> data) successCondition,
|
||||||
|
required String defaultError,
|
||||||
|
}) async {
|
||||||
|
final data = await request();
|
||||||
|
if (data != null && successCondition(data)) return null;
|
||||||
|
return {"error": data?['message'] ?? defaultError};
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _handleError(String message, Object error, StackTrace st) {
|
||||||
|
logSafe(message, level: LogLevel.error, error: error, stackTrace: st);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async {
|
static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async {
|
||||||
logSafe("Processing login success...");
|
logSafe("Processing login success...");
|
||||||
|
|
||||||
final jwtToken = data['token'];
|
await LocalStorage.setJwtToken(data['token']);
|
||||||
final refreshToken = data['refreshToken'];
|
|
||||||
final mpinToken = data['mpinToken'];
|
|
||||||
|
|
||||||
// Save tokens
|
|
||||||
await LocalStorage.setJwtToken(jwtToken);
|
|
||||||
await LocalStorage.setLoggedInUser(true);
|
await LocalStorage.setLoggedInUser(true);
|
||||||
|
|
||||||
if (refreshToken != null) {
|
if (data['refreshToken'] != null) {
|
||||||
await LocalStorage.setRefreshToken(refreshToken);
|
await LocalStorage.setRefreshToken(data['refreshToken']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mpinToken != null && mpinToken.isNotEmpty) {
|
if (data['mpinToken']?.isNotEmpty ?? false) {
|
||||||
await LocalStorage.setMpinToken(mpinToken);
|
await LocalStorage.setMpinToken(data['mpinToken']);
|
||||||
await LocalStorage.setIsMpin(true);
|
await LocalStorage.setIsMpin(true);
|
||||||
} else {
|
} else {
|
||||||
await LocalStorage.setIsMpin(false);
|
await LocalStorage.setIsMpin(false);
|
||||||
await LocalStorage.removeMpinToken();
|
await LocalStorage.removeMpinToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject controllers if not already registered
|
|
||||||
if (!Get.isRegistered<PermissionController>()) {
|
|
||||||
Get.put(PermissionController());
|
|
||||||
logSafe("✅ PermissionController injected after login.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Get.isRegistered<ProjectController>()) {
|
|
||||||
Get.put(ProjectController(), permanent: true);
|
|
||||||
logSafe("✅ ProjectController injected after login.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load data into controllers
|
|
||||||
await Get.find<PermissionController>().loadData(jwtToken);
|
|
||||||
await Get.find<ProjectController>().fetchProjects();
|
|
||||||
|
|
||||||
isLoggedIn = true;
|
isLoggedIn = true;
|
||||||
logSafe("✅ Login flow completed and controllers initialized.");
|
logSafe("✅ Login flow completed and controllers initialized.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
51
lib/helpers/services/device_info_service.dart
Normal file
51
lib/helpers/services/device_info_service.dart
Normal 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();
|
||||||
|
}
|
||||||
141
lib/helpers/services/firebase/firebase_messaging_service.dart
Normal file
141
lib/helpers/services/firebase/firebase_messaging_service.dart
Normal 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);
|
||||||
|
}
|
||||||
42
lib/helpers/services/local_notification_service.dart
Normal file
42
lib/helpers/services/local_notification_service.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
391
lib/helpers/services/notification_action_handler.dart
Normal file
391
lib/helpers/services/notification_action_handler.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,124 +20,125 @@ class LocalStorage {
|
|||||||
static const String _employeeInfoKey = "employee_info";
|
static const String _employeeInfoKey = "employee_info";
|
||||||
static const String _mpinTokenKey = "mpinToken";
|
static const String _mpinTokenKey = "mpinToken";
|
||||||
static const String _isMpinKey = "isMpin";
|
static const String _isMpinKey = "isMpin";
|
||||||
|
static const String _fcmTokenKey = "fcm_token";
|
||||||
|
static const String _menuStorageKey = "dynamic_menus";
|
||||||
|
// In LocalStorage
|
||||||
|
static const String _recentTenantKey = "recent_tenant_id";
|
||||||
|
|
||||||
|
static Future<bool> setRecentTenantId(String tenantId) =>
|
||||||
|
preferences.setString(_recentTenantKey, tenantId);
|
||||||
|
|
||||||
|
static String? getRecentTenantId() =>
|
||||||
|
_initialized ? preferences.getString(_recentTenantKey) : null;
|
||||||
|
|
||||||
|
static Future<bool> removeRecentTenantId() =>
|
||||||
|
preferences.remove(_recentTenantKey);
|
||||||
|
|
||||||
static SharedPreferences? _preferencesInstance;
|
static SharedPreferences? _preferencesInstance;
|
||||||
|
static bool _initialized = false;
|
||||||
|
|
||||||
|
static bool get isInitialized => _initialized;
|
||||||
|
|
||||||
static SharedPreferences get preferences {
|
static SharedPreferences get preferences {
|
||||||
if (_preferencesInstance == null) {
|
if (_preferencesInstance == null) {
|
||||||
throw ("Call LocalStorage.init() to initialize local storage");
|
throw ("Call LocalStorage.init() before using it");
|
||||||
}
|
}
|
||||||
return _preferencesInstance!;
|
return _preferencesInstance!;
|
||||||
}
|
}
|
||||||
// In LocalStorage class
|
|
||||||
|
|
||||||
static Future<bool> setUserPermissions(
|
/// Initialization (idempotent)
|
||||||
List<UserPermission> permissions) async {
|
|
||||||
// Convert the list of UserPermission objects to a List<Map<String, dynamic>>
|
|
||||||
final jsonList = permissions.map((e) => e.toJson()).toList();
|
|
||||||
|
|
||||||
// Save as a JSON string
|
|
||||||
return preferences.setString(_userPermissionsKey, jsonEncode(jsonList));
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<UserPermission> getUserPermissions() {
|
|
||||||
final storedJson = preferences.getString(_userPermissionsKey);
|
|
||||||
|
|
||||||
if (storedJson != null) {
|
|
||||||
final List<dynamic> parsedList = jsonDecode(storedJson);
|
|
||||||
return parsedList
|
|
||||||
.map((e) => UserPermission.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<bool> removeUserPermissions() async {
|
|
||||||
return preferences.remove(_userPermissionsKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store EmployeeInfo
|
|
||||||
static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) async {
|
|
||||||
final jsonData = employeeInfo.toJson();
|
|
||||||
return preferences.setString(_employeeInfoKey, jsonEncode(jsonData));
|
|
||||||
}
|
|
||||||
|
|
||||||
static EmployeeInfo? getEmployeeInfo() {
|
|
||||||
final storedJson = preferences.getString(_employeeInfoKey);
|
|
||||||
if (storedJson != null) {
|
|
||||||
final Map<String, dynamic> json = jsonDecode(storedJson);
|
|
||||||
return EmployeeInfo.fromJson(json);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<bool> removeEmployeeInfo() async {
|
|
||||||
return preferences.remove(_employeeInfoKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other methods for handling JWT, refresh token, etc.
|
|
||||||
static Future<void> init() async {
|
static Future<void> init() async {
|
||||||
|
if (_initialized) return;
|
||||||
_preferencesInstance = await SharedPreferences.getInstance();
|
_preferencesInstance = await SharedPreferences.getInstance();
|
||||||
await initData();
|
await _initData();
|
||||||
|
_initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> initData() async {
|
static Future<void> _initData() async {
|
||||||
SharedPreferences preferences = await SharedPreferences.getInstance();
|
|
||||||
AuthService.isLoggedIn = preferences.getBool(_loggedInUserKey) ?? false;
|
AuthService.isLoggedIn = preferences.getBool(_loggedInUserKey) ?? false;
|
||||||
ThemeCustomizer.fromJSON(preferences.getString(_themeCustomizerKey));
|
ThemeCustomizer.fromJSON(preferences.getString(_themeCustomizerKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> setLoggedInUser(bool loggedIn) async {
|
// ================== Sidebar Menu ==================
|
||||||
return preferences.setBool(_loggedInUserKey, loggedIn);
|
static Future<bool> setMenus(List<MenuItem> menus) async {
|
||||||
|
try {
|
||||||
|
final jsonList = menus.map((e) => e.toJson()).toList();
|
||||||
|
return preferences.setString(_menuStorageKey, jsonEncode(jsonList));
|
||||||
|
} catch (e) {
|
||||||
|
print("Error saving menus: $e");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> setCustomizer(ThemeCustomizer themeCustomizer) {
|
static List<MenuItem> getMenus() {
|
||||||
return preferences.setString(_themeCustomizerKey, themeCustomizer.toJSON());
|
if (!_initialized) return [];
|
||||||
|
final storedJson = preferences.getString(_menuStorageKey);
|
||||||
|
if (storedJson == null) return [];
|
||||||
|
try {
|
||||||
|
return (jsonDecode(storedJson) as List)
|
||||||
|
.map((e) => MenuItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
print("Error loading menus: $e");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> setLanguage(Language language) {
|
static Future<bool> removeMenus() => preferences.remove(_menuStorageKey);
|
||||||
return preferences.setString(_languageKey, language.locale.languageCode);
|
|
||||||
|
// ================== User Permissions ==================
|
||||||
|
static Future<bool> setUserPermissions(
|
||||||
|
List<UserPermission> permissions) async {
|
||||||
|
final jsonList = permissions.map((e) => e.toJson()).toList();
|
||||||
|
return preferences.setString(_userPermissionsKey, jsonEncode(jsonList));
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? getLanguage() {
|
static List<UserPermission> getUserPermissions() {
|
||||||
return preferences.getString(_languageKey);
|
if (!_initialized) return [];
|
||||||
|
final storedJson = preferences.getString(_userPermissionsKey);
|
||||||
|
if (storedJson == null) return [];
|
||||||
|
return (jsonDecode(storedJson) as List)
|
||||||
|
.map((e) => UserPermission.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> removeLoggedInUser() async {
|
static Future<bool> removeUserPermissions() =>
|
||||||
return preferences.remove(_loggedInUserKey);
|
preferences.remove(_userPermissionsKey);
|
||||||
|
|
||||||
|
// ================== Employee Info ==================
|
||||||
|
static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) => preferences
|
||||||
|
.setString(_employeeInfoKey, jsonEncode(employeeInfo.toJson()));
|
||||||
|
|
||||||
|
static EmployeeInfo? getEmployeeInfo() {
|
||||||
|
if (!_initialized) return null;
|
||||||
|
final storedJson = preferences.getString(_employeeInfoKey);
|
||||||
|
return storedJson == null
|
||||||
|
? null
|
||||||
|
: EmployeeInfo.fromJson(jsonDecode(storedJson));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add methods to handle JWT and Refresh Token
|
static Future<bool> removeEmployeeInfo() =>
|
||||||
static Future<bool> setToken(String key, String token) {
|
preferences.remove(_employeeInfoKey);
|
||||||
return preferences.setString(key, token);
|
|
||||||
|
// ================== Login / Logout ==================
|
||||||
|
static Future<bool> setLoggedInUser(bool loggedIn) =>
|
||||||
|
preferences.setBool(_loggedInUserKey, loggedIn);
|
||||||
|
|
||||||
|
static Future<bool> removeLoggedInUser() =>
|
||||||
|
preferences.remove(_loggedInUserKey);
|
||||||
|
|
||||||
|
static Future<void> logout() async {
|
||||||
|
try {
|
||||||
|
final refreshToken = getRefreshToken();
|
||||||
|
final fcmToken = getFcmToken();
|
||||||
|
|
||||||
|
if (refreshToken != null && fcmToken != null) {
|
||||||
|
await AuthService.logoutApi(refreshToken, fcmToken);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print("Logout API error: $e");
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? getToken(String key) {
|
|
||||||
return preferences.getString(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<bool> removeToken(String key) {
|
|
||||||
return preferences.remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience methods for getting the JWT and Refresh tokens
|
|
||||||
static String? getJwtToken() {
|
|
||||||
return getToken(_jwtTokenKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String? getRefreshToken() {
|
|
||||||
return getToken(_refreshTokenKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<bool> setJwtToken(String jwtToken) {
|
|
||||||
return setToken(_jwtTokenKey, jwtToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<bool> setRefreshToken(String refreshToken) {
|
|
||||||
return setToken(_refreshTokenKey, refreshToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> logout() async {
|
|
||||||
await removeLoggedInUser();
|
await removeLoggedInUser();
|
||||||
await removeToken(_jwtTokenKey);
|
await removeToken(_jwtTokenKey);
|
||||||
await removeToken(_refreshTokenKey);
|
await removeToken(_refreshTokenKey);
|
||||||
@ -144,56 +146,83 @@ static Future<void> logout() async {
|
|||||||
await removeEmployeeInfo();
|
await removeEmployeeInfo();
|
||||||
await removeMpinToken();
|
await removeMpinToken();
|
||||||
await removeIsMpin();
|
await removeIsMpin();
|
||||||
|
await removeMenus();
|
||||||
|
await removeRecentTenantId();
|
||||||
await preferences.remove("mpin_verified");
|
await preferences.remove("mpin_verified");
|
||||||
await preferences.remove(_languageKey);
|
await preferences.remove(_languageKey);
|
||||||
await preferences.remove(_themeCustomizerKey);
|
await preferences.remove(_themeCustomizerKey);
|
||||||
await preferences.remove('selectedProjectId');
|
await preferences.remove('selectedProjectId');
|
||||||
|
|
||||||
if (Get.isRegistered<ProjectController>()) {
|
if (Get.isRegistered<ProjectController>()) {
|
||||||
Get.find<ProjectController>().clearProjects();
|
Get.find<ProjectController>().clearProjects();
|
||||||
}
|
}
|
||||||
|
|
||||||
Get.offAllNamed('/auth/login-option');
|
Get.offAllNamed('/auth/login-option');
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> setMpinToken(String token) {
|
// ================== Theme & Language ==================
|
||||||
return preferences.setString(_mpinTokenKey, token);
|
static Future<bool> setCustomizer(ThemeCustomizer themeCustomizer) =>
|
||||||
}
|
preferences.setString(_themeCustomizerKey, themeCustomizer.toJSON());
|
||||||
|
|
||||||
static String? getMpinToken() {
|
static Future<bool> setLanguage(Language language) =>
|
||||||
return preferences.getString(_mpinTokenKey);
|
preferences.setString(_languageKey, language.locale.languageCode);
|
||||||
}
|
|
||||||
|
static String? getLanguage() =>
|
||||||
static Future<bool> removeMpinToken() {
|
_initialized ? preferences.getString(_languageKey) : null;
|
||||||
return preferences.remove(_mpinTokenKey);
|
|
||||||
}
|
// ================== Tokens ==================
|
||||||
|
static Future<bool> setToken(String key, String token) =>
|
||||||
// MPIN Enabled flag
|
preferences.setString(key, token);
|
||||||
static Future<bool> setIsMpin(bool value) {
|
|
||||||
return preferences.setBool(_isMpinKey, value);
|
static String? getToken(String key) =>
|
||||||
}
|
_initialized ? preferences.getString(key) : null;
|
||||||
|
|
||||||
static bool getIsMpin() {
|
static Future<bool> removeToken(String key) => preferences.remove(key);
|
||||||
return preferences.getBool(_isMpinKey) ?? false;
|
|
||||||
}
|
static Future<bool> setJwtToken(String jwtToken) =>
|
||||||
|
setToken(_jwtTokenKey, jwtToken);
|
||||||
static Future<bool> removeIsMpin() {
|
|
||||||
return preferences.remove(_isMpinKey);
|
static Future<bool> setRefreshToken(String refreshToken) =>
|
||||||
}
|
setToken(_refreshTokenKey, refreshToken);
|
||||||
|
|
||||||
static Future<bool> setBool(String key, bool value) async {
|
static String? getJwtToken() => getToken(_jwtTokenKey);
|
||||||
return preferences.setBool(key, value);
|
|
||||||
}
|
static String? getRefreshToken() => getToken(_refreshTokenKey);
|
||||||
|
|
||||||
static bool? getBool(String key) {
|
// ================== FCM Token ==================
|
||||||
return preferences.getBool(key);
|
static Future<void> setFcmToken(String token) =>
|
||||||
}
|
preferences.setString(_fcmTokenKey, token);
|
||||||
// Save and retrieve String values
|
|
||||||
static String? getString(String key) {
|
static String? getFcmToken() =>
|
||||||
return preferences.getString(key);
|
_initialized ? preferences.getString(_fcmTokenKey) : null;
|
||||||
}
|
|
||||||
|
// ================== MPIN ==================
|
||||||
static Future<bool> saveString(String key, String value) async {
|
static Future<bool> setMpinToken(String token) =>
|
||||||
return preferences.setString(key, value);
|
preferences.setString(_mpinTokenKey, token);
|
||||||
}
|
|
||||||
|
static String? getMpinToken() =>
|
||||||
|
_initialized ? preferences.getString(_mpinTokenKey) : null;
|
||||||
|
|
||||||
|
static Future<bool> removeMpinToken() => preferences.remove(_mpinTokenKey);
|
||||||
|
|
||||||
|
static Future<bool> setIsMpin(bool value) =>
|
||||||
|
preferences.setBool(_isMpinKey, value);
|
||||||
|
|
||||||
|
static bool getIsMpin() =>
|
||||||
|
_initialized ? preferences.getBool(_isMpinKey) ?? false : false;
|
||||||
|
|
||||||
|
static Future<bool> removeIsMpin() => preferences.remove(_isMpinKey);
|
||||||
|
|
||||||
|
// ================== Generic Set/Get ==================
|
||||||
|
static Future<bool> setBool(String key, bool value) =>
|
||||||
|
preferences.setBool(key, value);
|
||||||
|
|
||||||
|
static bool? getBool(String key) =>
|
||||||
|
_initialized ? preferences.getBool(key) : null;
|
||||||
|
|
||||||
|
static String? getString(String key) =>
|
||||||
|
_initialized ? preferences.getString(key) : null;
|
||||||
|
|
||||||
|
static Future<bool> saveString(String key, String value) =>
|
||||||
|
preferences.setString(key, value);
|
||||||
}
|
}
|
||||||
|
|||||||
163
lib/helpers/services/tenant_service.dart
Normal file
163
lib/helpers/services/tenant_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
129
lib/helpers/utils/base_bottom_sheet.dart
Normal file
129
lib/helpers/utils/base_bottom_sheet.dart
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
|
||||||
|
class BaseBottomSheet extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
final VoidCallback onCancel;
|
||||||
|
final VoidCallback onSubmit;
|
||||||
|
final bool isSubmitting;
|
||||||
|
final String submitText;
|
||||||
|
final Color submitColor;
|
||||||
|
final IconData submitIcon;
|
||||||
|
final bool showButtons;
|
||||||
|
final Widget? bottomContent;
|
||||||
|
|
||||||
|
const BaseBottomSheet({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
required this.onCancel,
|
||||||
|
required this.onSubmit,
|
||||||
|
this.isSubmitting = false,
|
||||||
|
this.submitText = 'Submit',
|
||||||
|
this.submitColor = Colors.indigo,
|
||||||
|
this.submitIcon = Icons.check_circle_outline,
|
||||||
|
this.showButtons = true,
|
||||||
|
this.bottomContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: mediaQuery.viewInsets,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 60),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardColor,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black12,
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
// 👈 prevents overlap with nav bar
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
MySpacing.height(5),
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 5,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(12),
|
||||||
|
MyText.titleLarge(title, fontWeight: 700),
|
||||||
|
MySpacing.height(12),
|
||||||
|
child,
|
||||||
|
MySpacing.height(12),
|
||||||
|
if (showButtons) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: onCancel,
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
label: MyText.bodyMedium(
|
||||||
|
"Cancel",
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: isSubmitting ? null : onSubmit,
|
||||||
|
icon: Icon(submitIcon, color: Colors.white),
|
||||||
|
label: MyText.bodyMedium(
|
||||||
|
isSubmitting ? "Submitting..." : submitText,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: submitColor,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (bottomContent != null) ...[
|
||||||
|
MySpacing.height(12),
|
||||||
|
bottomContent!,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,11 +1,10 @@
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:marco/helpers/services/app_logger.dart';
|
|
||||||
|
|
||||||
class DateTimeUtils {
|
class DateTimeUtils {
|
||||||
static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) {
|
/// Converts a UTC datetime string to local time and formats it.
|
||||||
|
static String convertUtcToLocal(String utcTimeString,
|
||||||
|
{String format = 'dd-MM-yyyy'}) {
|
||||||
try {
|
try {
|
||||||
logSafe('convertUtcToLocal: input="$utcTimeString", format="$format"');
|
|
||||||
|
|
||||||
final parsed = DateTime.parse(utcTimeString);
|
final parsed = DateTime.parse(utcTimeString);
|
||||||
final utcDateTime = DateTime.utc(
|
final utcDateTime = DateTime.utc(
|
||||||
parsed.year,
|
parsed.year,
|
||||||
@ -17,22 +16,35 @@ class DateTimeUtils {
|
|||||||
parsed.millisecond,
|
parsed.millisecond,
|
||||||
parsed.microsecond,
|
parsed.microsecond,
|
||||||
);
|
);
|
||||||
logSafe('Parsed (assumed UTC): $utcDateTime');
|
|
||||||
|
|
||||||
final localDateTime = utcDateTime.toLocal();
|
final localDateTime = utcDateTime.toLocal();
|
||||||
logSafe('Converted to Local: $localDateTime');
|
return _formatDateTime(localDateTime, format: format);
|
||||||
|
} catch (e) {
|
||||||
final formatted = _formatDateTime(localDateTime, format: format);
|
|
||||||
logSafe('Formatted Local Time: $formatted');
|
|
||||||
|
|
||||||
return formatted;
|
|
||||||
} catch (e, stackTrace) {
|
|
||||||
logSafe('DateTime conversion failed: $e', error: e, stackTrace: stackTrace);
|
|
||||||
return 'Invalid Date';
|
return 'Invalid Date';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _formatDateTime(DateTime dateTime, {String format = 'dd-MM-yyyy'}) {
|
/// Public utility for formatting any DateTime.
|
||||||
|
static String formatDate(DateTime date, String format) {
|
||||||
|
try {
|
||||||
|
return DateFormat(format).format(date);
|
||||||
|
} catch (e) {
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a date string using the given format.
|
||||||
|
static DateTime? parseDate(String dateString, String format) {
|
||||||
|
try {
|
||||||
|
return DateFormat(format).parse(dateString);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal formatter with default format.
|
||||||
|
static String _formatDateTime(DateTime dateTime,
|
||||||
|
{String format = 'dd-MM-yyyy'}) {
|
||||||
return DateFormat(format).format(dateTime);
|
return DateFormat(format).format(dateTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,120 @@
|
|||||||
|
/// Contains all role, permission, and entity UUIDs used for access control across the application.
|
||||||
class Permissions {
|
class Permissions {
|
||||||
|
// ------------------- Project Management ------------------------------
|
||||||
|
/// Permission to manage master data (like dropdowns, configurations)
|
||||||
static const String manageMaster = "588a8824-f924-4955-82d8-fc51956cf323";
|
static const String manageMaster = "588a8824-f924-4955-82d8-fc51956cf323";
|
||||||
|
|
||||||
|
/// Permission to create, edit, delete projects
|
||||||
static const String manageProject = "172fc9b6-755b-4f62-ab26-55c34a330614";
|
static const String manageProject = "172fc9b6-755b-4f62-ab26-55c34a330614";
|
||||||
|
|
||||||
|
/// Permission to view list of all projects
|
||||||
static const String viewProjects = "6ea44136-987e-44ba-9e5d-1cf8f5837ebc";
|
static const String viewProjects = "6ea44136-987e-44ba-9e5d-1cf8f5837ebc";
|
||||||
|
|
||||||
|
/// Permission to assign employees to a project
|
||||||
|
static const String assignToProject = "b94802ce-0689-4643-9e1d-11c86950c35b";
|
||||||
|
|
||||||
|
// ------------------- Employee Management -----------------------------
|
||||||
|
/// Permission to manage employee records
|
||||||
static const String manageEmployees = "a97d366a-c2bb-448d-be93-402bd2324566";
|
static const String manageEmployees = "a97d366a-c2bb-448d-be93-402bd2324566";
|
||||||
static const String manageProjectInfra ="f2aee20a-b754-4537-8166-f9507b44585b";
|
|
||||||
static const String viewProjectInfra = "c7b68e33-72f0-474f-bd96-77636427ecc8";
|
/// Permission to view all employees
|
||||||
static const String regularizeAttendance ="57802c4a-00aa-4a1f-a048-fd2f70dd44b6";
|
static const String viewAllEmployees = "60611762-7f8a-4fb5-b53f-b1139918796b";
|
||||||
static const String assignToProject = "fbd213e0-0250-46f1-9f5f-4b2a1e6e76a3";
|
|
||||||
static const String infrastructure = "9666de86-d7c7-4d3d-acaa-fcd6d6b81f3c";
|
/// Permission to view only team members (subordinate employees)
|
||||||
|
static const String viewTeamMembers = "b82d2b7e-0d52-45f3-997b-c008ea460e7f";
|
||||||
|
|
||||||
|
// ------------------- Project Infrastructure --------------------------
|
||||||
|
/// Permission to manage project infrastructure (e.g., site details)
|
||||||
|
static const String manageProjectInfra = "cf2825ad-453b-46aa-91d9-27c124d63373";
|
||||||
|
|
||||||
|
/// Permission to view infrastructure-related details
|
||||||
|
static const String viewProjectInfra = "8d7cc6e3-9147-41f7-aaa7-fa507e450bd4";
|
||||||
|
|
||||||
|
// ------------------- Attendance Management ---------------------------
|
||||||
|
/// Permission to regularize (edit/update) attendance records
|
||||||
|
static const String regularizeAttendance = "57802c4a-00aa-4a1f-a048-fd2f70dd44b6";
|
||||||
|
|
||||||
|
// ------------------- Task Management ---------------------------------
|
||||||
|
/// Permission to create and manage tasks
|
||||||
static const String manageTask = "08752f33-3b29-4816-b76b-ea8a968ed3c5";
|
static const String manageTask = "08752f33-3b29-4816-b76b-ea8a968ed3c5";
|
||||||
|
|
||||||
|
/// Permission to approve tasks
|
||||||
|
static const String approveTask = "db4e40c5-2ba9-4b6d-b8a6-a16a250ff99c";
|
||||||
|
|
||||||
|
/// Permission to view task lists and details
|
||||||
static const String viewTask = "9fcc5f87-25e3-4846-90ac-67a71ab92e3c";
|
static const String viewTask = "9fcc5f87-25e3-4846-90ac-67a71ab92e3c";
|
||||||
|
|
||||||
|
/// Permission to assign tasks for reporting
|
||||||
static const String assignReportTask = "6a32379b-8b3f-49a6-8c48-4b7ac1b55dc2";
|
static const String assignReportTask = "6a32379b-8b3f-49a6-8c48-4b7ac1b55dc2";
|
||||||
|
|
||||||
|
// ------------------- Directory Roles ---------------------------------
|
||||||
|
/// Admin-level directory access
|
||||||
static const String directoryAdmin = "4286a13b-bb40-4879-8c6d-18e9e393beda";
|
static const String directoryAdmin = "4286a13b-bb40-4879-8c6d-18e9e393beda";
|
||||||
|
|
||||||
|
/// Manager-level directory access
|
||||||
static const String directoryManager = "62668630-13ce-4f52-a0f0-db38af2230c5";
|
static const String directoryManager = "62668630-13ce-4f52-a0f0-db38af2230c5";
|
||||||
|
|
||||||
|
/// Basic directory user access
|
||||||
|
static const String directoryUser = "0f919170-92d4-4337-abd3-49b66fc871bb";
|
||||||
|
|
||||||
|
// ------------------- Expense Permissions -----------------------------
|
||||||
|
/// View only own expenses
|
||||||
|
static const String expenseViewSelf = "385be49f-8fde-440e-bdbc-3dffeb8dd116";
|
||||||
|
|
||||||
|
/// View all employee expenses
|
||||||
|
static const String expenseViewAll = "01e06444-9ca7-4df4-b900-8c3fa051b92f";
|
||||||
|
|
||||||
|
/// Create/upload new expenses
|
||||||
|
static const String expenseUpload = "0f57885d-bcb2-4711-ac95-d841ace6d5a7";
|
||||||
|
|
||||||
|
/// Review submitted expenses
|
||||||
|
static const String expenseReview = "1f4bda08-1873-449a-bb66-3e8222bd871b";
|
||||||
|
|
||||||
|
/// Approve or reject expenses
|
||||||
|
static const String expenseApprove = "eaafdd76-8aac-45f9-a530-315589c6deca";
|
||||||
|
|
||||||
|
/// Process expenses for payment or final action
|
||||||
|
static const String expenseProcess = "ea5a1529-4ee8-4828-80ea-0e23c9d4dd11";
|
||||||
|
|
||||||
|
/// Full access to manage all expense operations
|
||||||
|
static const String expenseManage = "ea5a1529-4ee8-4828-80ea-0e23c9d4dd11";
|
||||||
|
|
||||||
|
/// ID used to track expenses in "Draft" status
|
||||||
|
static const String expenseDraft = "297e0d8f-f668-41b5-bfea-e03b354251c8";
|
||||||
|
|
||||||
|
/// List of user IDs who rejected the expense (used for audit trail)
|
||||||
|
static const List<String> expenseRejectedBy = [
|
||||||
|
"d1ee5eec-24b6-4364-8673-a8f859c60729",
|
||||||
|
"965eda62-7907-4963-b4a1-657fb0b2724b",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ------------------- Application Roles -------------------------------
|
||||||
|
/// Application role ID for users with full expense management rights
|
||||||
|
static const String expenseManagement = "a4e25142-449b-4334-a6e5-22f70e4732d7";
|
||||||
|
|
||||||
|
// ------------------- Document Entities -------------------------------
|
||||||
|
/// Entity ID for project documents
|
||||||
|
static const String projectEntity = "c8fe7115-aa27-43bc-99f4-7b05fabe436e";
|
||||||
|
|
||||||
|
/// Entity ID for employee documents
|
||||||
|
static const String employeeEntity = "dbb9555a-7a0c-40f2-a9ed-f0463f1ceed7";
|
||||||
|
|
||||||
|
// ------------------- Document Permissions ----------------------------
|
||||||
|
/// Permission to view documents
|
||||||
|
static const String viewDocument = "71189504-f1c8-4ca5-8db6-810497be2854";
|
||||||
|
|
||||||
|
/// Permission to upload documents
|
||||||
|
static const String uploadDocument = "3f6d1f67-6fa5-4b7c-b17b-018d4fe4aab8";
|
||||||
|
|
||||||
|
/// Permission to modify documents
|
||||||
|
static const String modifyDocument = "c423fd81-6273-4b9d-bb5e-76a0fb343833";
|
||||||
|
|
||||||
|
/// Permission to delete documents
|
||||||
|
static const String deleteDocument = "40863a13-5a66-469d-9b48-135bc5dbf486";
|
||||||
|
|
||||||
|
/// Permission to download documents
|
||||||
|
static const String downloadDocument = "404373d0-860f-490e-a575-1c086ffbce1d";
|
||||||
|
|
||||||
|
/// Permission to verify documents
|
||||||
|
static const String verifyDocument = "13a1f30f-38d1-41bf-8e7a-b75189aab8e0";
|
||||||
}
|
}
|
||||||
|
|||||||
271
lib/helpers/utils/validators.dart
Normal file
271
lib/helpers/utils/validators.dart
Normal 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 1–9)
|
||||||
|
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 (9–18 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, 8–36 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 (8–36 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 (9–18 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);
|
||||||
|
}
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
|
||||||
class CommentEditorCard extends StatelessWidget {
|
class CommentEditorCard extends StatefulWidget {
|
||||||
final quill.QuillController controller;
|
final quill.QuillController controller;
|
||||||
final VoidCallback onCancel;
|
final VoidCallback onCancel;
|
||||||
final Future<void> Function(quill.QuillController controller) onSave;
|
final Future<void> Function(quill.QuillController controller) onSave;
|
||||||
@ -13,13 +14,31 @@ class CommentEditorCard extends StatelessWidget {
|
|||||||
required this.onSave,
|
required this.onSave,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CommentEditorCard> createState() => _CommentEditorCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CommentEditorCardState extends State<CommentEditorCard> {
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
|
||||||
|
Future<void> _handleSave() async {
|
||||||
|
if (_isSubmitting) return;
|
||||||
|
setState(() => _isSubmitting = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await widget.onSave(widget.controller);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isSubmitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
quill.QuillSimpleToolbar(
|
quill.QuillSimpleToolbar(
|
||||||
controller: controller,
|
controller: widget.controller,
|
||||||
configurations: const quill.QuillSimpleToolbarConfigurations(
|
configurations: const quill.QuillSimpleToolbarConfigurations(
|
||||||
showBoldButton: true,
|
showBoldButton: true,
|
||||||
showItalicButton: true,
|
showItalicButton: true,
|
||||||
@ -48,7 +67,7 @@ class CommentEditorCard extends StatelessWidget {
|
|||||||
multiRowsDisplay: false,
|
multiRowsDisplay: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 38),
|
const SizedBox(height: 24),
|
||||||
Container(
|
Container(
|
||||||
height: 140,
|
height: 140,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@ -58,7 +77,7 @@ class CommentEditorCard extends StatelessWidget {
|
|||||||
color: const Color(0xFFFDFDFD),
|
color: const Color(0xFFFDFDFD),
|
||||||
),
|
),
|
||||||
child: quill.QuillEditor.basic(
|
child: quill.QuillEditor.basic(
|
||||||
controller: controller,
|
controller: widget.controller,
|
||||||
configurations: const quill.QuillEditorConfigurations(
|
configurations: const quill.QuillEditorConfigurations(
|
||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
expands: false,
|
expands: false,
|
||||||
@ -66,32 +85,50 @@ class CommentEditorCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 16),
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
// 👇 Buttons same as BaseBottomSheet
|
||||||
child: Wrap(
|
Row(
|
||||||
spacing: 8,
|
|
||||||
children: [
|
children: [
|
||||||
OutlinedButton.icon(
|
Expanded(
|
||||||
onPressed: onCancel,
|
child: ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.close, size: 18),
|
onPressed: _isSubmitting ? null : widget.onCancel,
|
||||||
label: const Text("Cancel"),
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
style: OutlinedButton.styleFrom(
|
label: MyText.bodyMedium(
|
||||||
foregroundColor: Colors.grey[700],
|
"Cancel",
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ElevatedButton.icon(
|
),
|
||||||
onPressed: () => onSave(controller),
|
const SizedBox(width: 12),
|
||||||
icon: const Icon(Icons.save, size: 18),
|
Expanded(
|
||||||
label: const Text("Save"),
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _isSubmitting ? null : _handleSave,
|
||||||
|
icon: const Icon(Icons.check_circle_outline, color: Colors.white),
|
||||||
|
label: MyText.bodyMedium(
|
||||||
|
_isSubmitting ? "Submitting..." : "Submit",
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.indigo,
|
backgroundColor: Colors.indigo,
|
||||||
foregroundColor: Colors.white,
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
89
lib/helpers/widgets/custom_app_bar.dart
Normal file
89
lib/helpers/widgets/custom_app_bar.dart
Normal 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);
|
||||||
|
}
|
||||||
462
lib/helpers/widgets/dashbaord/attendance_overview_chart.dart
Normal file
462
lib/helpers/widgets/dashbaord/attendance_overview_chart.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
393
lib/helpers/widgets/dashbaord/dashboard_overview_widgets.dart
Normal file
393
lib/helpers/widgets/dashbaord/dashboard_overview_widgets.dart
Normal 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);
|
||||||
|
}
|
||||||
354
lib/helpers/widgets/dashbaord/project_progress_chart.dart
Normal file
354
lib/helpers/widgets/dashbaord/project_progress_chart.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
lib/helpers/widgets/expense/expense_detail_helpers.dart
Normal file
96
lib/helpers/widgets/expense/expense_detail_helpers.dart
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
|
||||||
|
/// Returns a formatted color for the expense status.
|
||||||
|
Color getExpenseStatusColor(String? status, {String? colorCode}) {
|
||||||
|
if (colorCode != null && colorCode.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
return Color(int.parse(colorCode.replaceFirst('#', '0xff')));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
switch (status) {
|
||||||
|
case 'Approval Pending':
|
||||||
|
return Colors.orange;
|
||||||
|
case 'Process Pending':
|
||||||
|
return Colors.blue;
|
||||||
|
case 'Rejected':
|
||||||
|
return Colors.red;
|
||||||
|
case 'Paid':
|
||||||
|
return Colors.green;
|
||||||
|
default:
|
||||||
|
return Colors.black;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats amount to ₹ currency string.
|
||||||
|
String formatExpenseAmount(double amount) {
|
||||||
|
return NumberFormat.currency(
|
||||||
|
locale: 'en_IN', symbol: '₹ ', decimalDigits: 2)
|
||||||
|
.format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Label/Value block as reusable widget.
|
||||||
|
Widget labelValueBlock(String label, String value) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodySmall(label, fontWeight: 600),
|
||||||
|
MySpacing.height(4),
|
||||||
|
MyText.bodySmall(value,
|
||||||
|
fontWeight: 500, softWrap: true, maxLines: null),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Skeleton loader for lists.
|
||||||
|
Widget buildLoadingSkeleton() => ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: 5,
|
||||||
|
itemBuilder: (_, __) => Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300], borderRadius: BorderRadius.circular(10)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Expandable description widget.
|
||||||
|
class ExpandableDescription extends StatefulWidget {
|
||||||
|
final String description;
|
||||||
|
const ExpandableDescription({Key? key, required this.description})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ExpandableDescription> createState() => _ExpandableDescriptionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExpandableDescriptionState extends State<ExpandableDescription> {
|
||||||
|
bool isExpanded = false;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isLong = widget.description.length > 100;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodySmall(
|
||||||
|
widget.description,
|
||||||
|
maxLines: isExpanded ? null : 2,
|
||||||
|
overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||||
|
fontWeight: 500,
|
||||||
|
),
|
||||||
|
if (isLong || !isExpanded)
|
||||||
|
InkWell(
|
||||||
|
onTap: () => setState(() => isExpanded = !isExpanded),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: MyText.labelSmall(
|
||||||
|
isExpanded ? 'Show less' : 'Show more',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
422
lib/helpers/widgets/expense/expense_form_widgets.dart
Normal file
422
lib/helpers/widgets/expense/expense_form_widgets.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
346
lib/helpers/widgets/expense/expense_main_components.dart
Normal file
346
lib/helpers/widgets/expense/expense_main_components.dart
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import 'package:marco/controller/project_controller.dart';
|
||||||
|
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
||||||
|
import 'package:marco/helpers/utils/date_time_utils.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/model/expense/expense_list_model.dart';
|
||||||
|
import 'package:marco/view/expense/expense_detail_screen.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
||||||
|
|
||||||
|
class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||||
|
final ProjectController projectController;
|
||||||
|
|
||||||
|
const ExpenseAppBar({required this.projectController, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Size get preferredSize => const Size.fromHeight(72);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppBar(
|
||||||
|
backgroundColor: const Color(0xFFF5F5F5),
|
||||||
|
elevation: 0.5,
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
titleSpacing: 0,
|
||||||
|
title: Padding(
|
||||||
|
padding: MySpacing.xy(16, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new,
|
||||||
|
color: Colors.black, size: 20),
|
||||||
|
onPressed: () => Get.offNamed('/dashboard'),
|
||||||
|
),
|
||||||
|
MySpacing.width(8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.titleLarge('Expenses', fontWeight: 700),
|
||||||
|
MySpacing.height(2),
|
||||||
|
GetBuilder<ProjectController>(
|
||||||
|
builder: (_) {
|
||||||
|
final name = projectController.selectedProject?.name ??
|
||||||
|
'Select Project';
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.work_outline,
|
||||||
|
size: 14, color: Colors.grey),
|
||||||
|
MySpacing.width(4),
|
||||||
|
Expanded(
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
name,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchAndFilter extends StatelessWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
final VoidCallback onFilterTap;
|
||||||
|
final ExpenseController expenseController;
|
||||||
|
|
||||||
|
const SearchAndFilter({
|
||||||
|
required this.controller,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.onFilterTap,
|
||||||
|
required this.expenseController,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: MySpacing.fromLTRB(12, 10, 12, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 35,
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
onChanged: onChanged,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
prefixIcon:
|
||||||
|
const Icon(Icons.search, size: 20, color: Colors.grey),
|
||||||
|
hintText: 'Search expenses...',
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.width(4),
|
||||||
|
Obx(() {
|
||||||
|
return IconButton(
|
||||||
|
icon: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.tune, color: Colors.black),
|
||||||
|
if (expenseController.isFilterApplied)
|
||||||
|
Positioned(
|
||||||
|
top: -1,
|
||||||
|
right: -1,
|
||||||
|
child: Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onPressed: onFilterTap,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToggleButtonsRow extends StatelessWidget {
|
||||||
|
final bool isHistoryView;
|
||||||
|
final ValueChanged<bool> onToggle;
|
||||||
|
|
||||||
|
const ToggleButtonsRow({
|
||||||
|
required this.isHistoryView,
|
||||||
|
required this.onToggle,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: MySpacing.fromLTRB(8, 12, 8, 5),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF0F0F0),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_ToggleButton(
|
||||||
|
label: 'Expenses',
|
||||||
|
icon: Icons.receipt_long,
|
||||||
|
selected: !isHistoryView,
|
||||||
|
onTap: () => onToggle(false),
|
||||||
|
),
|
||||||
|
_ToggleButton(
|
||||||
|
label: 'History',
|
||||||
|
icon: Icons.history,
|
||||||
|
selected: isHistoryView,
|
||||||
|
onTap: () => onToggle(true),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ToggleButton extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ToggleButton({
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? Colors.red : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icon,
|
||||||
|
size: 16, color: selected ? Colors.white : Colors.grey),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
MyText.bodyMedium(label,
|
||||||
|
color: selected ? Colors.white : Colors.grey,
|
||||||
|
fontWeight: 600),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExpenseList extends StatelessWidget {
|
||||||
|
final List<ExpenseModel> expenseList;
|
||||||
|
final Future<void> Function()? onViewDetail;
|
||||||
|
|
||||||
|
const ExpenseList({
|
||||||
|
required this.expenseList,
|
||||||
|
this.onViewDetail,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
void _showDeleteConfirmation(BuildContext context, ExpenseModel expense) {
|
||||||
|
final ExpenseController controller = Get.find<ExpenseController>();
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => ConfirmDialog(
|
||||||
|
title: "Delete Expense",
|
||||||
|
message: "Are you sure you want to delete this draft expense?",
|
||||||
|
confirmText: "Delete",
|
||||||
|
cancelText: "Cancel",
|
||||||
|
icon: Icons.delete_forever,
|
||||||
|
confirmColor: Colors.redAccent,
|
||||||
|
onConfirm: () async {
|
||||||
|
await controller.deleteExpense(expense.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (expenseList.isEmpty && !Get.find<ExpenseController>().isLoading.value) {
|
||||||
|
return Center(child: MyText.bodyMedium('No expenses found.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
|
||||||
|
itemCount: expenseList.length,
|
||||||
|
separatorBuilder: (_, __) =>
|
||||||
|
Divider(color: Colors.grey.shade300, height: 20),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final expense = expenseList[index];
|
||||||
|
final formattedDate = DateTimeUtils.convertUtcToLocal(
|
||||||
|
expense.transactionDate.toIso8601String(),
|
||||||
|
format: 'dd MMM yyyy',
|
||||||
|
);
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: () async {
|
||||||
|
final result = await Get.to(
|
||||||
|
() => ExpenseDetailScreen(expenseId: expense.id),
|
||||||
|
arguments: {'expense': expense},
|
||||||
|
);
|
||||||
|
if (result == true && onViewDetail != null) {
|
||||||
|
await onViewDetail!();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
MyText.bodyMedium(expense.expensesType.name,
|
||||||
|
fontWeight: 600),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
MyText.bodyMedium('${expense.formattedAmount}',
|
||||||
|
fontWeight: 600),
|
||||||
|
if (expense.status.name.toLowerCase() == 'draft') ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () =>
|
||||||
|
_showDeleteConfirmation(context, expense),
|
||||||
|
child: const Icon(Icons.delete,
|
||||||
|
color: Colors.red, size: 20),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
MyText.bodySmall(formattedDate, fontWeight: 500),
|
||||||
|
const Spacer(),
|
||||||
|
MyText.bodySmall(expense.status.name, fontWeight: 500),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
179
lib/helpers/widgets/my_confirmation_dialog.dart
Normal file
179
lib/helpers/widgets/my_confirmation_dialog.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,8 +4,7 @@ import 'package:marco/helpers/widgets/my_spacing.dart';
|
|||||||
import 'package:marco/helpers/utils/my_shadow.dart';
|
import 'package:marco/helpers/utils/my_shadow.dart';
|
||||||
|
|
||||||
class SkeletonLoaders {
|
class SkeletonLoaders {
|
||||||
|
static Widget buildLoadingSkeleton() {
|
||||||
static Widget buildLoadingSkeleton() {
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 360,
|
height: 360,
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -32,8 +31,343 @@ static Widget buildLoadingSkeleton() {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Date Skeleton Loader
|
||||||
|
static Widget dateSkeletonLoader() {
|
||||||
|
return Container(
|
||||||
|
height: 14,
|
||||||
|
width: 90,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart Skeleton Loader
|
||||||
|
static Widget chartSkeletonLoader() {
|
||||||
|
return MyCard.bordered(
|
||||||
|
margin: MySpacing.only(bottom: 12),
|
||||||
|
paddingAll: 16,
|
||||||
|
borderRadiusAll: 16,
|
||||||
|
shadow: MyShadow(
|
||||||
|
elevation: 1.5,
|
||||||
|
position: MyShadowPosition.bottom,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Chart Title Placeholder
|
||||||
|
Container(
|
||||||
|
height: 14,
|
||||||
|
width: 120,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(20),
|
||||||
|
|
||||||
|
// Chart Bars (variable height for realism)
|
||||||
|
SizedBox(
|
||||||
|
height: 180,
|
||||||
|
child: Row(
|
||||||
|
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() {
|
||||||
@ -63,25 +397,37 @@ static Widget buildLoadingSkeleton() {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(height: 14, width: 100, color: Colors.grey.shade300),
|
Container(
|
||||||
|
height: 14,
|
||||||
|
width: 100,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
MySpacing.width(8),
|
MySpacing.width(8),
|
||||||
Container(height: 12, width: 60, color: Colors.grey.shade300),
|
Container(
|
||||||
|
height: 12, width: 60, color: Colors.grey.shade300),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
MySpacing.height(8),
|
MySpacing.height(8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.email, size: 16, color: Colors.grey.shade300),
|
Icon(Icons.email,
|
||||||
|
size: 16, color: Colors.grey.shade300),
|
||||||
MySpacing.width(4),
|
MySpacing.width(4),
|
||||||
Container(height: 10, width: 140, color: Colors.grey.shade300),
|
Container(
|
||||||
|
height: 10,
|
||||||
|
width: 140,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
MySpacing.height(8),
|
MySpacing.height(8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.phone, size: 16, color: Colors.grey.shade300),
|
Icon(Icons.phone,
|
||||||
|
size: 16, color: Colors.grey.shade300),
|
||||||
MySpacing.width(4),
|
MySpacing.width(4),
|
||||||
Container(height: 10, width: 100, color: Colors.grey.shade300),
|
Container(
|
||||||
|
height: 10,
|
||||||
|
width: 100,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -122,16 +468,28 @@ static Widget buildLoadingSkeleton() {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(height: 12, width: 100, color: Colors.grey.shade300),
|
Container(
|
||||||
|
height: 12,
|
||||||
|
width: 100,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
MySpacing.height(8),
|
MySpacing.height(8),
|
||||||
Container(height: 10, width: 80, color: Colors.grey.shade300),
|
Container(
|
||||||
|
height: 10,
|
||||||
|
width: 80,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
MySpacing.height(12),
|
MySpacing.height(12),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Container(height: 28, width: 60, color: Colors.grey.shade300),
|
Container(
|
||||||
|
height: 28,
|
||||||
|
width: 60,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
MySpacing.width(8),
|
MySpacing.width(8),
|
||||||
Container(height: 28, width: 60, color: Colors.grey.shade300),
|
Container(
|
||||||
|
height: 28,
|
||||||
|
width: 60,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -167,7 +525,8 @@ static Widget buildLoadingSkeleton() {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Container(height: 14, width: 120, color: Colors.grey.shade300),
|
Container(
|
||||||
|
height: 14, width: 120, color: Colors.grey.shade300),
|
||||||
Icon(Icons.add_circle, color: Colors.grey.shade300),
|
Icon(Icons.add_circle, color: Colors.grey.shade300),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -226,6 +585,147 @@ static Widget buildLoadingSkeleton() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Widget expenseListSkeletonLoader() {
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
|
||||||
|
itemCount: 6, // Show 6 skeleton items
|
||||||
|
separatorBuilder: (_, __) =>
|
||||||
|
Divider(color: Colors.grey.shade300, height: 20),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Title and Amount
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 14,
|
||||||
|
width: 120,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
height: 14,
|
||||||
|
width: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// Date and Status
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 12,
|
||||||
|
width: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Container(
|
||||||
|
height: 12,
|
||||||
|
width: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Widget employeeSkeletonCard() {
|
||||||
|
return MyCard.bordered(
|
||||||
|
margin: MySpacing.only(bottom: 12),
|
||||||
|
paddingAll: 12,
|
||||||
|
borderRadiusAll: 12,
|
||||||
|
shadow: MyShadow(
|
||||||
|
elevation: 1.5,
|
||||||
|
position: MyShadowPosition.bottom,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Avatar
|
||||||
|
Container(
|
||||||
|
height: 35,
|
||||||
|
width: 35,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.width(12),
|
||||||
|
|
||||||
|
// Name, org, email, phone
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(height: 12, width: 120, color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(6),
|
||||||
|
Container(height: 10, width: 80, color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(8),
|
||||||
|
|
||||||
|
// Email placeholder
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.email_outlined,
|
||||||
|
size: 14, color: Colors.grey.shade300),
|
||||||
|
MySpacing.width(4),
|
||||||
|
Container(
|
||||||
|
height: 10, width: 140, color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
|
||||||
|
// Phone placeholder
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.phone_outlined,
|
||||||
|
size: 14, color: Colors.grey.shade300),
|
||||||
|
MySpacing.width(4),
|
||||||
|
Container(
|
||||||
|
height: 10, width: 100, color: Colors.grey.shade300),
|
||||||
|
MySpacing.width(8),
|
||||||
|
Container(
|
||||||
|
height: 16,
|
||||||
|
width: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
|
||||||
|
// Tags placeholder
|
||||||
|
Container(height: 8, width: 80, color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Arrow
|
||||||
|
Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static Widget contactSkeletonCard() {
|
static Widget contactSkeletonCard() {
|
||||||
return MyCard.bordered(
|
return MyCard.bordered(
|
||||||
margin: MySpacing.only(bottom: 12),
|
margin: MySpacing.only(bottom: 12),
|
||||||
@ -278,6 +778,5 @@ static Widget buildLoadingSkeleton() {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
32
lib/helpers/widgets/my_refresh_indicator.dart
Normal file
32
lib/helpers/widgets/my_refresh_indicator.dart
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class MyRefreshIndicator extends StatelessWidget {
|
||||||
|
final Future<void> Function() onRefresh;
|
||||||
|
final Widget child;
|
||||||
|
final Color color;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final double strokeWidth;
|
||||||
|
final double displacement;
|
||||||
|
|
||||||
|
const MyRefreshIndicator({
|
||||||
|
super.key,
|
||||||
|
required this.onRefresh,
|
||||||
|
required this.child,
|
||||||
|
this.color = Colors.white,
|
||||||
|
this.backgroundColor = Colors.blueAccent,
|
||||||
|
this.strokeWidth = 3.0,
|
||||||
|
this.displacement = 40.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: onRefresh,
|
||||||
|
color: color,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
strokeWidth: strokeWidth,
|
||||||
|
displacement: displacement,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:marco/helpers/widgets/avatar.dart';
|
import 'package:marco/helpers/widgets/avatar.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
|
||||||
class TeamBottomSheet {
|
class TeamBottomSheet {
|
||||||
static void show({
|
static void show({
|
||||||
@ -9,46 +11,61 @@ class TeamBottomSheet {
|
|||||||
}) {
|
}) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
shape: const RoundedRectangleBorder(
|
isScrollControlled: true,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
backgroundColor: Colors.transparent,
|
||||||
),
|
builder: (_) {
|
||||||
backgroundColor: Colors.white,
|
return BaseBottomSheet(
|
||||||
builder: (_) => Padding(
|
title: 'Team Members',
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
onCancel: () => Navigator.pop(context),
|
||||||
child: Column(
|
onSubmit: () {},
|
||||||
mainAxisSize: MainAxisSize.min,
|
showButtons: false,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: _TeamMemberList(teamMembers: teamMembers),
|
||||||
children: [
|
);
|
||||||
// Title and Close Icon
|
},
|
||||||
Row(
|
);
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
}
|
||||||
children: [
|
}
|
||||||
MyText.bodyLarge("Team Members", fontWeight: 600),
|
|
||||||
IconButton(
|
class _TeamMemberList extends StatelessWidget {
|
||||||
icon: const Icon(Icons.close, size: 20, color: Colors.black54),
|
final List<dynamic> teamMembers;
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
const _TeamMemberList({required this.teamMembers});
|
||||||
],
|
|
||||||
),
|
@override
|
||||||
const Divider(thickness: 1.2),
|
Widget build(BuildContext context) {
|
||||||
// Team Member Rows
|
if (teamMembers.isEmpty) {
|
||||||
...teamMembers.map((member) => _buildTeamMemberRow(member)),
|
return Center(
|
||||||
],
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
"No team members found.",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.grey,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Widget _buildTeamMemberRow(dynamic member) {
|
return ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: teamMembers.length,
|
||||||
|
separatorBuilder: (_, __) => const Divider(thickness: 0.8, height: 12),
|
||||||
|
itemBuilder: (_, index) {
|
||||||
|
final member = teamMembers[index];
|
||||||
|
final String name = member.firstName ?? 'Unnamed';
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Avatar(firstName: member.firstName, lastName: '', size: 36),
|
Avatar(firstName: member.firstName, lastName: '', size: 36),
|
||||||
const SizedBox(width: 10),
|
MySpacing.width(10),
|
||||||
MyText.bodyMedium(member.firstName, fontWeight: 500),
|
MyText.bodyMedium(name, fontWeight: 500),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
import 'package:marco/helpers/widgets/avatar.dart';
|
import 'package:marco/helpers/widgets/avatar.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
import 'package:marco/model/directory/contact_bucket_list_model.dart';
|
import 'package:marco/model/directory/contact_bucket_list_model.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
|
||||||
class TeamMembersBottomSheet {
|
class TeamMembersBottomSheet {
|
||||||
static void show(
|
static void show(
|
||||||
@ -11,8 +13,9 @@ class TeamMembersBottomSheet {
|
|||||||
bool canEdit = false,
|
bool canEdit = false,
|
||||||
VoidCallback? onEdit,
|
VoidCallback? onEdit,
|
||||||
}) {
|
}) {
|
||||||
// Ensure the owner is at the top of the list
|
|
||||||
final ownerId = bucket.createdBy.id;
|
final ownerId = bucket.createdBy.id;
|
||||||
|
|
||||||
|
// Ensure owner is listed first
|
||||||
members.sort((a, b) {
|
members.sort((a, b) {
|
||||||
if (a.id == ownerId) return -1;
|
if (a.id == ownerId) return -1;
|
||||||
if (b.id == ownerId) return 1;
|
if (b.id == ownerId) return 1;
|
||||||
@ -23,51 +26,63 @@ class TeamMembersBottomSheet {
|
|||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
isDismissible: true,
|
builder: (_) {
|
||||||
enableDrag: true,
|
return BaseBottomSheet(
|
||||||
builder: (context) {
|
title: 'Bucket Details',
|
||||||
return SafeArea(
|
onCancel: () => Navigator.pop(context),
|
||||||
child: Container(
|
onSubmit: () {}, // Not used, but required
|
||||||
decoration: const BoxDecoration(
|
showButtons: false,
|
||||||
color: Colors.white,
|
child: _TeamContent(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
bucket: bucket,
|
||||||
|
members: members,
|
||||||
|
canEdit: canEdit,
|
||||||
|
onEdit: onEdit,
|
||||||
|
ownerId: ownerId,
|
||||||
),
|
),
|
||||||
child: DraggableScrollableSheet(
|
);
|
||||||
expand: false,
|
},
|
||||||
initialChildSize: 0.7,
|
);
|
||||||
minChildSize: 0.5,
|
}
|
||||||
maxChildSize: 0.95,
|
}
|
||||||
builder: (context, scrollController) {
|
|
||||||
|
class _TeamContent extends StatelessWidget {
|
||||||
|
final ContactBucket bucket;
|
||||||
|
final List<dynamic> members;
|
||||||
|
final bool canEdit;
|
||||||
|
final VoidCallback? onEdit;
|
||||||
|
final String ownerId;
|
||||||
|
|
||||||
|
const _TeamContent({
|
||||||
|
required this.bucket,
|
||||||
|
required this.members,
|
||||||
|
required this.canEdit,
|
||||||
|
this.onEdit,
|
||||||
|
required this.ownerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 6),
|
_buildHeader(),
|
||||||
Container(
|
_buildInfo(),
|
||||||
width: 36,
|
_buildMembersTitle(),
|
||||||
height: 4,
|
MySpacing.height(8),
|
||||||
decoration: BoxDecoration(
|
SizedBox(
|
||||||
color: Colors.grey.shade300,
|
height: 300,
|
||||||
borderRadius: BorderRadius.circular(2),
|
child: _buildMemberList(),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(height: 10),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
MyText.titleMedium(
|
Widget _buildHeader() {
|
||||||
'Bucket Details',
|
return Padding(
|
||||||
fontWeight: 700,
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// Header with title and edit
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: MyText.titleMedium(
|
child: MyText.titleMedium(bucket.name, fontWeight: 700),
|
||||||
bucket.name,
|
|
||||||
fontWeight: 700,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (canEdit)
|
if (canEdit)
|
||||||
IconButton(
|
IconButton(
|
||||||
@ -77,11 +92,12 @@ class TeamMembersBottomSheet {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Info
|
Widget _buildInfo() {
|
||||||
Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -95,8 +111,7 @@ class TeamMembersBottomSheet {
|
|||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.contacts_outlined,
|
const Icon(Icons.contacts_outlined, size: 14, color: Colors.grey),
|
||||||
size: 14, color: Colors.grey),
|
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
MyText.labelSmall(
|
MyText.labelSmall(
|
||||||
'${bucket.numberOfContacts} contact(s)',
|
'${bucket.numberOfContacts} contact(s)',
|
||||||
@ -104,8 +119,7 @@ class TeamMembersBottomSheet {
|
|||||||
color: Colors.red,
|
color: Colors.red,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Icon(Icons.ios_share_outlined,
|
const Icon(Icons.ios_share_outlined, size: 14, color: Colors.grey),
|
||||||
size: 14, color: Colors.grey),
|
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
MyText.labelSmall(
|
MyText.labelSmall(
|
||||||
'Shared with (${members.length})',
|
'Shared with (${members.length})',
|
||||||
@ -114,68 +128,56 @@ class TeamMembersBottomSheet {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Padding(
|
MySpacing.height(8),
|
||||||
padding: const EdgeInsets.only(top: 8),
|
Row(
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.edit_outlined,
|
const Icon(Icons.edit_outlined, size: 14, color: Colors.grey),
|
||||||
size: 14, color: Colors.grey),
|
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
MyText.labelSmall(
|
MyText.labelSmall(
|
||||||
canEdit
|
canEdit ? 'Can be edited by you' : 'You don’t have edit access',
|
||||||
? 'Can be edited by you'
|
|
||||||
: 'You don’t have edit access',
|
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: canEdit ? Colors.green : Colors.grey,
|
color: canEdit ? Colors.green : Colors.grey,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
MySpacing.height(12),
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Divider(thickness: 1),
|
const Divider(thickness: 1),
|
||||||
const SizedBox(height: 6),
|
|
||||||
MyText.labelLarge(
|
|
||||||
'Shared with',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: Colors.black,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const SizedBox(height: 4),
|
Widget _buildMembersTitle() {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: MyText.labelLarge('Shared with', fontWeight: 700, color: Colors.black),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Expanded(
|
Widget _buildMemberList() {
|
||||||
child: Padding(
|
if (members.isEmpty) {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
return Center(
|
||||||
child: members.isEmpty
|
|
||||||
? Center(
|
|
||||||
child: MyText.bodySmall(
|
child: MyText.bodySmall(
|
||||||
"No team members found.",
|
"No team members found.",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
: ListView.separated(
|
}
|
||||||
controller: scrollController,
|
|
||||||
|
return ListView.separated(
|
||||||
itemCount: members.length,
|
itemCount: members.length,
|
||||||
separatorBuilder: (_, __) =>
|
separatorBuilder: (_, __) => const SizedBox(height: 6),
|
||||||
const SizedBox(height: 4),
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final member = members[index];
|
final member = members[index];
|
||||||
final firstName = member.firstName ?? '';
|
final firstName = member.firstName ?? '';
|
||||||
final lastName = member.lastName ?? '';
|
final lastName = member.lastName ?? '';
|
||||||
final isOwner =
|
final isOwner = member.id == ownerId;
|
||||||
member.id == bucket.createdBy.id;
|
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: Avatar(
|
leading: Avatar(firstName: firstName, lastName: lastName, size: 32),
|
||||||
firstName: firstName,
|
|
||||||
lastName: lastName,
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@ -186,14 +188,11 @@ class TeamMembersBottomSheet {
|
|||||||
),
|
),
|
||||||
if (isOwner)
|
if (isOwner)
|
||||||
Container(
|
Container(
|
||||||
margin:
|
margin: const EdgeInsets.only(left: 6),
|
||||||
const EdgeInsets.only(left: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 6, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.red.shade50,
|
color: Colors.red.shade50,
|
||||||
borderRadius:
|
borderRadius: BorderRadius.circular(4),
|
||||||
BorderRadius.circular(4),
|
|
||||||
),
|
),
|
||||||
child: MyText.labelSmall(
|
child: MyText.labelSmall(
|
||||||
"Owner",
|
"Owner",
|
||||||
@ -209,18 +208,6 @@ class TeamMembersBottomSheet {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
lib/helpers/widgets/tenant/all_organization_selector.dart
Normal file
82
lib/helpers/widgets/tenant/all_organization_selector.dart
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
130
lib/helpers/widgets/tenant/organization_selector.dart
Normal file
130
lib/helpers/widgets/tenant/organization_selector.dart
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
143
lib/helpers/widgets/tenant/service_selector.dart
Normal file
143
lib/helpers/widgets/tenant/service_selector.dart
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,35 +1,50 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
|
|
||||||
import 'package:marco/helpers/services/app_initializer.dart';
|
import 'package:marco/helpers/services/app_initializer.dart';
|
||||||
import 'package:marco/view/my_app.dart';
|
import 'package:marco/view/my_app.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:marco/helpers/theme/app_notifier.dart';
|
import 'package:marco/helpers/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/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 MyApp(),
|
child: const MainWrapper(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e, stacktrace) {
|
} catch (e, stacktrace) {
|
||||||
logSafe('App failed to initialize.',
|
logSafe(
|
||||||
|
'App failed to initialize.',
|
||||||
level: LogLevel.error,
|
level: LogLevel.error,
|
||||||
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(
|
||||||
@ -38,7 +53,40 @@ Future<void> main() async {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
class MainWrapper extends StatefulWidget {
|
||||||
|
const MainWrapper({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MainWrapper> createState() => _MainWrapperState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainWrapperState extends State<MainWrapper> {
|
||||||
|
List<ConnectivityResult> _connectivityStatus = [ConnectivityResult.none];
|
||||||
|
final Connectivity _connectivity = Connectivity();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeConnectivity();
|
||||||
|
_connectivity.onConnectivityChanged.listen((results) {
|
||||||
|
setState(() => _connectivityStatus = results);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initializeConnectivity() async {
|
||||||
|
final result = await _connectivity.checkConnectivity();
|
||||||
|
setState(() => _connectivityStatus = result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bool isOffline =
|
||||||
|
_connectivityStatus.contains(ConnectivityResult.none);
|
||||||
|
return isOffline
|
||||||
|
? const MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false, home: OfflineScreen())
|
||||||
|
: const MyApp();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
184
lib/model/all_organization_model.dart
Normal file
184
lib/model/all_organization_model.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
115
lib/model/attendance/attendance_log_view_model.dart
Normal file
115
lib/model/attendance/attendance_log_view_model.dart
Normal 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;
|
||||||
|
}
|
||||||
@ -1,59 +1,365 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
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';
|
||||||
|
|
||||||
class AttendanceActionButton extends StatefulWidget {
|
class AttendanceActionButton extends StatefulWidget {
|
||||||
final dynamic employee;
|
final dynamic employee;
|
||||||
final AttendanceController attendanceController;
|
final AttendanceController attendanceController;
|
||||||
|
|
||||||
const AttendanceActionButton({
|
const AttendanceActionButton({
|
||||||
Key? key,
|
super.key,
|
||||||
required this.employee,
|
required this.employee,
|
||||||
required this.attendanceController,
|
required this.attendanceController,
|
||||||
}) : super(key: key);
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AttendanceActionButton> createState() => _AttendanceActionButtonState();
|
State<AttendanceActionButton> createState() => _AttendanceActionButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
||||||
|
late final String uniqueLogKey;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
uniqueLogKey = AttendanceButtonHelper.getUniqueKey(
|
||||||
|
widget.employee.employeeId,
|
||||||
|
widget.employee.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
widget.attendanceController.uploadingStates.putIfAbsent(
|
||||||
|
uniqueLogKey,
|
||||||
|
() => false.obs,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<DateTime?> _pickRegularizationTime(DateTime checkInTime) async {
|
||||||
|
final pickedTime = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: TimeOfDay.fromDateTime(DateTime.now()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pickedTime == null) return null;
|
||||||
|
|
||||||
|
final selected = DateTime(
|
||||||
|
checkInTime.year,
|
||||||
|
checkInTime.month,
|
||||||
|
checkInTime.day,
|
||||||
|
pickedTime.hour,
|
||||||
|
pickedTime.minute,
|
||||||
|
);
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
if (selected.isBefore(checkInTime)) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Invalid Time",
|
||||||
|
message: "Time must be after check-in.",
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.isAfter(now)) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Invalid Time",
|
||||||
|
message: "Future time is not allowed.",
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleButtonPressed() async {
|
||||||
|
final controller = widget.attendanceController;
|
||||||
|
final projectController = Get.find<ProjectController>();
|
||||||
|
final selectedProjectId = projectController.selectedProject?.id;
|
||||||
|
|
||||||
|
if (selectedProjectId == null) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Project Required",
|
||||||
|
message: "Please select a project first",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.uploadingStates[uniqueLogKey]?.value = true;
|
||||||
|
|
||||||
|
int action;
|
||||||
|
String actionText;
|
||||||
|
bool imageCapture = true;
|
||||||
|
|
||||||
|
switch (widget.employee.activity) {
|
||||||
|
case 0:
|
||||||
|
case 4:
|
||||||
|
action = 0;
|
||||||
|
actionText = ButtonActions.checkIn;
|
||||||
|
break;
|
||||||
|
|
||||||
|
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;
|
||||||
|
actionText = ButtonActions.requestRegularize;
|
||||||
|
imageCapture = false;
|
||||||
|
} else if (widget.employee.checkOut != null && isOldCheckOut) {
|
||||||
|
action = 2;
|
||||||
|
actionText = ButtonActions.requestRegularize;
|
||||||
|
} else {
|
||||||
|
action = 1;
|
||||||
|
actionText = ButtonActions.checkOut;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
action = 2;
|
||||||
|
actionText = ButtonActions.requestRegularize;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
action = 0;
|
||||||
|
actionText = "Unknown Action";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? selectedTime;
|
||||||
|
|
||||||
|
final isYesterdayCheckIn = widget.employee.checkIn != null &&
|
||||||
|
DateUtils.isSameDay(
|
||||||
|
widget.employee.checkIn,
|
||||||
|
DateTime.now().subtract(const Duration(days: 1)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isYesterdayCheckIn &&
|
||||||
|
widget.employee.checkOut == null &&
|
||||||
|
actionText == ButtonActions.checkOut) {
|
||||||
|
selectedTime = await _pickRegularizationTime(widget.employee.checkIn!);
|
||||||
|
if (selectedTime == null) {
|
||||||
|
controller.uploadingStates[uniqueLogKey]?.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final comment = await _showCommentBottomSheet(
|
||||||
|
context,
|
||||||
|
actionText,
|
||||||
|
selectedTime: selectedTime,
|
||||||
|
checkInDate: widget.employee.checkIn,
|
||||||
|
);
|
||||||
|
if (comment == null || comment.isEmpty) {
|
||||||
|
controller.uploadingStates[uniqueLogKey]?.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? markTime;
|
||||||
|
if (actionText == ButtonActions.requestRegularize) {
|
||||||
|
selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!);
|
||||||
|
markTime = selectedTime != null
|
||||||
|
? DateFormat("hh:mm a").format(selectedTime)
|
||||||
|
: null;
|
||||||
|
} else if (selectedTime != null) {
|
||||||
|
markTime = DateFormat("hh:mm a").format(selectedTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await controller.captureAndUploadAttendance(
|
||||||
|
widget.employee.id,
|
||||||
|
widget.employee.employeeId,
|
||||||
|
selectedProjectId,
|
||||||
|
comment: comment,
|
||||||
|
action: action,
|
||||||
|
imageCapture: imageCapture,
|
||||||
|
markTime: markTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
showAppSnackbar(
|
||||||
|
title: success ? '${capitalizeFirstLetter(actionText)} Success' : 'Error',
|
||||||
|
message: success
|
||||||
|
? '${capitalizeFirstLetter(actionText)} marked successfully!'
|
||||||
|
: 'Failed to ${actionText.toLowerCase()}',
|
||||||
|
type: success ? SnackbarType.success : SnackbarType.error,
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.uploadingStates[uniqueLogKey]?.value = false;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await controller.fetchTodaysAttendance(selectedProjectId);
|
||||||
|
await controller.fetchAttendanceLogs(selectedProjectId);
|
||||||
|
await controller.fetchRegularizationLogs(selectedProjectId);
|
||||||
|
await controller.fetchProjectData(selectedProjectId);
|
||||||
|
controller.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(() {
|
||||||
|
final controller = widget.attendanceController;
|
||||||
|
final isUploading =
|
||||||
|
controller.uploadingStates[uniqueLogKey]?.value ?? false;
|
||||||
|
final emp = widget.employee;
|
||||||
|
|
||||||
|
final isYesterday =
|
||||||
|
AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut);
|
||||||
|
final isTodayApproved =
|
||||||
|
AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn);
|
||||||
|
final isApprovedButNotToday =
|
||||||
|
AttendanceButtonHelper.isApprovedButNotToday(
|
||||||
|
emp.activity, isTodayApproved);
|
||||||
|
|
||||||
|
final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled(
|
||||||
|
isUploading: isUploading,
|
||||||
|
isYesterday: isYesterday,
|
||||||
|
activity: emp.activity,
|
||||||
|
isApprovedButNotToday: isApprovedButNotToday,
|
||||||
|
);
|
||||||
|
|
||||||
|
final buttonText = AttendanceButtonHelper.getButtonText(
|
||||||
|
activity: emp.activity,
|
||||||
|
checkIn: emp.checkIn,
|
||||||
|
checkOut: emp.checkOut,
|
||||||
|
isTodayApproved: isTodayApproved,
|
||||||
|
);
|
||||||
|
|
||||||
|
final buttonColor = AttendanceButtonHelper.getButtonColor(
|
||||||
|
isYesterday: isYesterday,
|
||||||
|
isTodayApproved: isTodayApproved,
|
||||||
|
activity: emp.activity,
|
||||||
|
);
|
||||||
|
|
||||||
|
return AttendanceActionButtonUI(
|
||||||
|
isUploading: isUploading,
|
||||||
|
isButtonDisabled: isButtonDisabled,
|
||||||
|
buttonText: buttonText,
|
||||||
|
buttonColor: buttonColor,
|
||||||
|
onPressed: isButtonDisabled ? null : _handleButtonPressed,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AttendanceActionButtonUI extends StatelessWidget {
|
||||||
|
final bool isUploading;
|
||||||
|
final bool isButtonDisabled;
|
||||||
|
final String buttonText;
|
||||||
|
final Color buttonColor;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
const AttendanceActionButtonUI({
|
||||||
|
super.key,
|
||||||
|
required this.isUploading,
|
||||||
|
required this.isButtonDisabled,
|
||||||
|
required this.buttonText,
|
||||||
|
required this.buttonColor,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 30,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: onPressed,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: buttonColor,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
||||||
|
textStyle: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
child: isUploading
|
||||||
|
? Container(
|
||||||
|
width: 60,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.5),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (buttonText.toLowerCase() == 'approved')
|
||||||
|
const Icon(Icons.check, size: 16, color: Colors.green),
|
||||||
|
if (buttonText.toLowerCase() == 'rejected')
|
||||||
|
const Icon(Icons.close, size: 16, color: Colors.red),
|
||||||
|
if (buttonText.toLowerCase() == 'requested')
|
||||||
|
const Icon(Icons.hourglass_top,
|
||||||
|
size: 16, color: Colors.orange),
|
||||||
|
if (['approved', 'rejected', 'requested']
|
||||||
|
.contains(buttonText.toLowerCase()))
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
buttonText,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> _showCommentBottomSheet(
|
Future<String?> _showCommentBottomSheet(
|
||||||
BuildContext context, String actionText) async {
|
BuildContext context,
|
||||||
final TextEditingController commentController = TextEditingController();
|
String actionText, {
|
||||||
|
DateTime? selectedTime,
|
||||||
|
DateTime? checkInDate,
|
||||||
|
}) async {
|
||||||
|
final commentController = TextEditingController();
|
||||||
String? errorText;
|
String? errorText;
|
||||||
Get.find<ProjectController>().selectedProject?.id;
|
|
||||||
|
// 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,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.transparent,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
),
|
),
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setModalState) {
|
builder: (context, setModalState) {
|
||||||
|
void submit() {
|
||||||
|
final comment = commentController.text.trim();
|
||||||
|
if (comment.isEmpty) {
|
||||||
|
setModalState(() => errorText = 'Comment cannot be empty.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop(comment);
|
||||||
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
left: 16,
|
bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||||
right: 16,
|
child: BaseBottomSheet(
|
||||||
top: 24,
|
title: sheetTitle, // 👈 now showing full sentence as title
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
onCancel: () => Navigator.of(context).pop(),
|
||||||
),
|
onSubmit: submit,
|
||||||
child: Column(
|
isSubmitting: false,
|
||||||
mainAxisSize: MainAxisSize.min,
|
submitText: 'Submit',
|
||||||
children: [
|
child: TextField(
|
||||||
Text(
|
|
||||||
'Add Comment for ${capitalizeFirstLetter(actionText)}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextField(
|
|
||||||
controller: commentController,
|
controller: commentController,
|
||||||
maxLines: 4,
|
maxLines: 4,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@ -71,34 +377,6 @@ Future<String?> _showCommentBottomSheet(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
final comment = commentController.text.trim();
|
|
||||||
if (comment.isEmpty) {
|
|
||||||
setModalState(() {
|
|
||||||
errorText = 'Comment cannot be empty.';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Navigator.of(context).pop(comment);
|
|
||||||
},
|
|
||||||
child: const Text('Submit'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -107,325 +385,5 @@ Future<String?> _showCommentBottomSheet(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String capitalizeFirstLetter(String text) {
|
String capitalizeFirstLetter(String text) =>
|
||||||
if (text.isEmpty) return text;
|
text.isEmpty ? text : text[0].toUpperCase() + text.substring(1);
|
||||||
return text[0].toUpperCase() + text.substring(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
|
||||||
late final String uniqueLogKey;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
uniqueLogKey = AttendanceButtonHelper.getUniqueKey(
|
|
||||||
widget.employee.employeeId, widget.employee.id);
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!widget.attendanceController.uploadingStates
|
|
||||||
.containsKey(uniqueLogKey)) {
|
|
||||||
widget.attendanceController.uploadingStates[uniqueLogKey] = false.obs;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<DateTime?> showTimePickerForRegularization({
|
|
||||||
required BuildContext context,
|
|
||||||
required DateTime checkInTime,
|
|
||||||
}) async {
|
|
||||||
final pickedTime = await showTimePicker(
|
|
||||||
context: context,
|
|
||||||
initialTime: TimeOfDay.fromDateTime(DateTime.now()),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (pickedTime != null) {
|
|
||||||
final selectedDateTime = DateTime(
|
|
||||||
checkInTime.year,
|
|
||||||
checkInTime.month,
|
|
||||||
checkInTime.day,
|
|
||||||
pickedTime.hour,
|
|
||||||
pickedTime.minute,
|
|
||||||
);
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
|
||||||
|
|
||||||
if (selectedDateTime.isBefore(checkInTime)) {
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Invalid Time",
|
|
||||||
message: "Time must be after check-in.",
|
|
||||||
type: SnackbarType.warning,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
} else if (selectedDateTime.isAfter(now)) {
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Invalid Time",
|
|
||||||
message: "Future time is not allowed.",
|
|
||||||
type: SnackbarType.warning,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return selectedDateTime;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleButtonPressed(BuildContext context) async {
|
|
||||||
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = true;
|
|
||||||
|
|
||||||
final projectController = Get.find<ProjectController>();
|
|
||||||
final selectedProjectId = projectController.selectedProject?.id;
|
|
||||||
|
|
||||||
if (selectedProjectId == null) {
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Project Required",
|
|
||||||
message: "Please select a project first",
|
|
||||||
type: SnackbarType.error,
|
|
||||||
);
|
|
||||||
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int updatedAction;
|
|
||||||
String actionText;
|
|
||||||
bool imageCapture = true;
|
|
||||||
|
|
||||||
switch (widget.employee.activity) {
|
|
||||||
case 0:
|
|
||||||
updatedAction = 0;
|
|
||||||
actionText = ButtonActions.checkIn;
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
if (widget.employee.checkOut == null &&
|
|
||||||
AttendanceButtonHelper.isOlderThanDays(
|
|
||||||
widget.employee.checkIn, 2)) {
|
|
||||||
updatedAction = 2;
|
|
||||||
actionText = ButtonActions.requestRegularize;
|
|
||||||
imageCapture = false;
|
|
||||||
} else if (widget.employee.checkOut != null &&
|
|
||||||
AttendanceButtonHelper.isOlderThanDays(
|
|
||||||
widget.employee.checkOut, 2)) {
|
|
||||||
updatedAction = 2;
|
|
||||||
actionText = ButtonActions.requestRegularize;
|
|
||||||
} else {
|
|
||||||
updatedAction = 1;
|
|
||||||
actionText = ButtonActions.checkOut;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
updatedAction = 2;
|
|
||||||
actionText = ButtonActions.requestRegularize;
|
|
||||||
break;
|
|
||||||
case 4:
|
|
||||||
updatedAction = 0;
|
|
||||||
actionText = ButtonActions.checkIn;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
updatedAction = 0;
|
|
||||||
actionText = "Unknown Action";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime? selectedTime;
|
|
||||||
|
|
||||||
// ✅ New condition: Yesterday Check-In + CheckOut action
|
|
||||||
final isYesterdayCheckIn = widget.employee.checkIn != null &&
|
|
||||||
DateUtils.isSameDay(
|
|
||||||
widget.employee.checkIn,
|
|
||||||
DateTime.now().subtract(const Duration(days: 1)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isYesterdayCheckIn &&
|
|
||||||
widget.employee.checkOut == null &&
|
|
||||||
actionText == ButtonActions.checkOut) {
|
|
||||||
selectedTime = await showTimePickerForRegularization(
|
|
||||||
context: context,
|
|
||||||
checkInTime: widget.employee.checkIn!,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selectedTime == null) {
|
|
||||||
widget.attendanceController.uploadingStates[uniqueLogKey]?.value =
|
|
||||||
false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final userComment = await _showCommentBottomSheet(context, actionText);
|
|
||||||
if (userComment == null || userComment.isEmpty) {
|
|
||||||
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool success = false;
|
|
||||||
if (actionText == ButtonActions.requestRegularize) {
|
|
||||||
final regularizeTime = selectedTime ??
|
|
||||||
await showTimePickerForRegularization(
|
|
||||||
context: context,
|
|
||||||
checkInTime: widget.employee.checkIn!,
|
|
||||||
);
|
|
||||||
if (regularizeTime != null) {
|
|
||||||
final formattedSelectedTime =
|
|
||||||
DateFormat("hh:mm a").format(regularizeTime);
|
|
||||||
success = await widget.attendanceController.captureAndUploadAttendance(
|
|
||||||
widget.employee.id,
|
|
||||||
widget.employee.employeeId,
|
|
||||||
selectedProjectId,
|
|
||||||
comment: userComment,
|
|
||||||
action: updatedAction,
|
|
||||||
imageCapture: imageCapture,
|
|
||||||
markTime: formattedSelectedTime,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (selectedTime != null) {
|
|
||||||
// ✅ If selectedTime was picked in the new condition
|
|
||||||
final formattedSelectedTime = DateFormat("hh:mm a").format(selectedTime);
|
|
||||||
success = await widget.attendanceController.captureAndUploadAttendance(
|
|
||||||
widget.employee.id,
|
|
||||||
widget.employee.employeeId,
|
|
||||||
selectedProjectId,
|
|
||||||
comment: userComment,
|
|
||||||
action: updatedAction,
|
|
||||||
imageCapture: imageCapture,
|
|
||||||
markTime: formattedSelectedTime,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
success = await widget.attendanceController.captureAndUploadAttendance(
|
|
||||||
widget.employee.id,
|
|
||||||
widget.employee.employeeId,
|
|
||||||
selectedProjectId,
|
|
||||||
comment: userComment,
|
|
||||||
action: updatedAction,
|
|
||||||
imageCapture: imageCapture,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
showAppSnackbar(
|
|
||||||
title: success ? '${capitalizeFirstLetter(actionText)} Success' : 'Error',
|
|
||||||
message: success
|
|
||||||
? '${capitalizeFirstLetter(actionText)} marked successfully!'
|
|
||||||
: 'Failed to ${actionText.toLowerCase()}',
|
|
||||||
type: success ? SnackbarType.success : SnackbarType.error,
|
|
||||||
);
|
|
||||||
|
|
||||||
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false;
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
widget.attendanceController.fetchEmployeesByProject(selectedProjectId);
|
|
||||||
widget.attendanceController.fetchAttendanceLogs(selectedProjectId);
|
|
||||||
await widget.attendanceController
|
|
||||||
.fetchRegularizationLogs(selectedProjectId);
|
|
||||||
await widget.attendanceController.fetchProjectData(selectedProjectId);
|
|
||||||
widget.attendanceController.update();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Obx(() {
|
|
||||||
final isUploading =
|
|
||||||
widget.attendanceController.uploadingStates[uniqueLogKey]?.value ??
|
|
||||||
false;
|
|
||||||
|
|
||||||
final isYesterday = AttendanceButtonHelper.isLogFromYesterday(
|
|
||||||
widget.employee.checkIn, widget.employee.checkOut);
|
|
||||||
final isTodayApproved = AttendanceButtonHelper.isTodayApproved(
|
|
||||||
widget.employee.activity, widget.employee.checkIn);
|
|
||||||
final isApprovedButNotToday =
|
|
||||||
AttendanceButtonHelper.isApprovedButNotToday(
|
|
||||||
widget.employee.activity, isTodayApproved);
|
|
||||||
|
|
||||||
final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled(
|
|
||||||
isUploading: isUploading,
|
|
||||||
isYesterday: isYesterday,
|
|
||||||
activity: widget.employee.activity,
|
|
||||||
isApprovedButNotToday: isApprovedButNotToday,
|
|
||||||
);
|
|
||||||
|
|
||||||
final buttonText = AttendanceButtonHelper.getButtonText(
|
|
||||||
activity: widget.employee.activity,
|
|
||||||
checkIn: widget.employee.checkIn,
|
|
||||||
checkOut: widget.employee.checkOut,
|
|
||||||
isTodayApproved: isTodayApproved,
|
|
||||||
);
|
|
||||||
|
|
||||||
final buttonColor = AttendanceButtonHelper.getButtonColor(
|
|
||||||
isYesterday: isYesterday,
|
|
||||||
isTodayApproved: isTodayApproved,
|
|
||||||
activity: widget.employee.activity,
|
|
||||||
);
|
|
||||||
|
|
||||||
return AttendanceActionButtonUI(
|
|
||||||
isUploading: isUploading,
|
|
||||||
isButtonDisabled: isButtonDisabled,
|
|
||||||
buttonText: buttonText,
|
|
||||||
buttonColor: buttonColor,
|
|
||||||
onPressed:
|
|
||||||
isButtonDisabled ? null : () => _handleButtonPressed(context),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AttendanceActionButtonUI extends StatelessWidget {
|
|
||||||
final bool isUploading;
|
|
||||||
final bool isButtonDisabled;
|
|
||||||
final String buttonText;
|
|
||||||
final Color buttonColor;
|
|
||||||
final VoidCallback? onPressed;
|
|
||||||
|
|
||||||
const AttendanceActionButtonUI({
|
|
||||||
Key? key,
|
|
||||||
required this.isUploading,
|
|
||||||
required this.isButtonDisabled,
|
|
||||||
required this.buttonText,
|
|
||||||
required this.buttonColor,
|
|
||||||
required this.onPressed,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SizedBox(
|
|
||||||
height: 30,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: isButtonDisabled ? null : onPressed,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: buttonColor,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
|
||||||
textStyle: const TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
child: isUploading
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (buttonText.toLowerCase() == 'approved') ...[
|
|
||||||
const Icon(Icons.check, size: 16, color: Colors.green),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
] else if (buttonText.toLowerCase() == 'rejected') ...[
|
|
||||||
const Icon(Icons.close, size: 16, color: Colors.red),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
] else if (buttonText.toLowerCase() == 'requested') ...[
|
|
||||||
const Icon(Icons.hourglass_top,
|
|
||||||
size: 16, color: Colors.orange),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
],
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
buttonText,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
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/dashboard/attendance_screen_controller.dart';
|
import 'package:marco/controller/attendance/attendance_screen_controller.dart';
|
||||||
import 'package:intl/intl.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: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;
|
||||||
@ -18,7 +20,7 @@ class AttendanceFilterBottomSheet extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_AttendanceFilterBottomSheetState createState() =>
|
State<AttendanceFilterBottomSheet> createState() =>
|
||||||
_AttendanceFilterBottomSheetState();
|
_AttendanceFilterBottomSheetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,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);
|
||||||
@ -53,78 +120,124 @@ class _AttendanceFilterBottomSheetState
|
|||||||
{'label': 'Regularization Requests', 'value': 'regularizationRequests'},
|
{'label': 'Regularization Requests', 'value': 'regularizationRequests'},
|
||||||
];
|
];
|
||||||
|
|
||||||
final filteredViewOptions = viewOptions.where((item) {
|
final filteredOptions = viewOptions.where((item) {
|
||||||
if (item['value'] == 'regularizationRequests') {
|
return item['value'] != 'regularizationRequests' ||
|
||||||
return hasRegularizationPermission;
|
hasRegularizationPermission;
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
List<Widget> widgets = [
|
final List<Widget> widgets = [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: MyText.titleSmall(
|
child: MyText.titleSmall("View", fontWeight: 600),
|
||||||
"View",
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
...filteredOptions.map((item) {
|
||||||
...filteredViewOptions.map((item) {
|
|
||||||
return RadioListTile<String>(
|
return RadioListTile<String>(
|
||||||
dense: true,
|
dense: true,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
contentPadding: EdgeInsets.zero,
|
||||||
title: Text(item['label']!),
|
title: MyText.bodyMedium(
|
||||||
|
item['label']!,
|
||||||
|
fontWeight: 500,
|
||||||
|
),
|
||||||
value: item['value']!,
|
value: item['value']!,
|
||||||
groupValue: tempSelectedTab,
|
groupValue: tempSelectedTab,
|
||||||
onChanged: (value) => setState(() => tempSelectedTab = value!),
|
onChanged: (value) => setState(() => tempSelectedTab = value!),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 🔹 Organization filter
|
||||||
|
widgets.addAll([
|
||||||
|
const Divider(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12, bottom: 12),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: MyText.titleSmall("Choose Organization", fontWeight: 600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Obx(() {
|
||||||
|
if (widget.controller.isLoadingOrganizations.value) {
|
||||||
|
return Container(
|
||||||
|
height: 40,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 100,
|
||||||
|
height: 14,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (widget.controller.organizations.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
|
child: MyText.bodyMedium(
|
||||||
|
"No organizations found",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _buildOrganizationSelector(context);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 🔹 Date Range only for attendanceLogs
|
||||||
if (tempSelectedTab == 'attendanceLogs') {
|
if (tempSelectedTab == 'attendanceLogs') {
|
||||||
widgets.addAll([
|
widgets.addAll([
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
padding: const EdgeInsets.only(top: 12, bottom: 4),
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: MyText.titleSmall(
|
child: MyText.titleSmall("Date Range", fontWeight: 600),
|
||||||
"Date Range",
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
InkWell(
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
onTap: () => widget.controller.selectDateRangeForAttendance(
|
onTap: () async {
|
||||||
|
await widget.controller.selectDateRangeForAttendance(
|
||||||
context,
|
context,
|
||||||
widget.controller,
|
widget.controller,
|
||||||
),
|
);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
child: Ink(
|
child: Ink(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
border: Border.all(color: Colors.grey.shade400),
|
border: Border.all(color: Colors.grey.shade400),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
padding:
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.date_range, color: Colors.black87),
|
const Icon(Icons.date_range, color: Colors.black87),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: MyText.bodyMedium(
|
||||||
getLabelText(),
|
getLabelText(),
|
||||||
style: const TextStyle(
|
fontWeight: 500,
|
||||||
fontSize: 16,
|
|
||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(Icons.arrow_drop_down, color: Colors.black87),
|
const Icon(Icons.arrow_drop_down, color: Colors.black87),
|
||||||
@ -132,7 +245,6 @@ class _AttendanceFilterBottomSheetState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,49 +253,19 @@ class _AttendanceFilterBottomSheetState
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return ClipRRect(
|
||||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
child: SingleChildScrollView(
|
child: BaseBottomSheet(
|
||||||
child: Column(
|
title: "Attendance Filter",
|
||||||
mainAxisSize: MainAxisSize.min,
|
submitText: "Apply",
|
||||||
children: [
|
onCancel: () => Navigator.pop(context),
|
||||||
Padding(
|
onSubmit: () => Navigator.pop(context, {
|
||||||
padding: const EdgeInsets.only(top: 12, bottom: 8),
|
|
||||||
child: Center(
|
|
||||||
child: Container(
|
|
||||||
width: 40,
|
|
||||||
height: 4,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[400],
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
...buildMainFilters(),
|
|
||||||
const Divider(),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
child: SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: const Color.fromARGB(255, 95, 132, 255),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Text('Apply Filter'),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context, {
|
|
||||||
'selectedTab': tempSelectedTab,
|
'selectedTab': tempSelectedTab,
|
||||||
});
|
'selectedOrganization': widget.controller.selectedOrganization?.id,
|
||||||
},
|
}),
|
||||||
),
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
),
|
children: buildMainFilters(),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
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/date_time_utils.dart';
|
||||||
|
|
||||||
class AttendanceLogViewButton extends StatelessWidget {
|
class AttendanceLogViewButton extends StatefulWidget {
|
||||||
final dynamic employee;
|
final dynamic employee;
|
||||||
final dynamic attendanceController; // Use correct types as needed
|
final dynamic attendanceController;
|
||||||
|
|
||||||
const AttendanceLogViewButton({
|
const AttendanceLogViewButton({
|
||||||
Key? key,
|
Key? key,
|
||||||
@ -13,6 +14,12 @@ class AttendanceLogViewButton extends StatelessWidget {
|
|||||||
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,59 +56,47 @@ 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,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
),
|
),
|
||||||
backgroundColor: Theme.of(context).cardColor,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (context) => Padding(
|
builder: (context) {
|
||||||
padding: EdgeInsets.only(
|
Map<int, bool> expandedDescription = {};
|
||||||
left: 16,
|
|
||||||
right: 16,
|
return BaseBottomSheet(
|
||||||
top: 16,
|
title: "Attendance Log",
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
onCancel: () => Navigator.pop(context),
|
||||||
),
|
onSubmit: () => Navigator.pop(context),
|
||||||
child: SingleChildScrollView(
|
showButtons: false,
|
||||||
child: Column(
|
child: widget.attendanceController.attendenceLogsView.isEmpty
|
||||||
mainAxisSize: MainAxisSize.min,
|
? Padding(
|
||||||
children: [
|
|
||||||
// Header
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
MyText.titleMedium(
|
|
||||||
"Attendance Log",
|
|
||||||
fontWeight: 700,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
if (attendanceController.attendenceLogsView.isEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 24.0),
|
padding: const EdgeInsets.symmetric(vertical: 24.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: const [
|
children: [
|
||||||
Icon(Icons.info_outline, size: 40, color: Colors.grey),
|
Icon(Icons.info_outline, size: 40, color: Colors.grey),
|
||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
Text("No attendance logs available."),
|
MyText.bodySmall("No attendance logs available."),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
: StatefulBuilder(
|
||||||
ListView.separated(
|
builder: (context, setStateSB) {
|
||||||
|
return ListView.separated(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: attendanceController.attendenceLogsView.length,
|
itemCount:
|
||||||
|
widget.attendanceController.attendenceLogsView.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 16),
|
separatorBuilder: (_, __) => const SizedBox(height: 16),
|
||||||
itemBuilder: (_, index) {
|
itemBuilder: (_, index) {
|
||||||
final log = attendanceController.attendenceLogsView[index];
|
final log = widget
|
||||||
|
.attendanceController.attendenceLogsView[index];
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||||
@ -114,91 +109,41 @@ class AttendanceLogViewButton extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Header: Icon + Date + Time
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
_getLogIcon(log),
|
_getLogIcon(log),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 12),
|
||||||
Column(
|
|
||||||
crossAxisAlignment:
|
|
||||||
CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MyText.bodyLarge(
|
MyText.bodyLarge(
|
||||||
log.formattedDate ?? '-',
|
(log.formattedDate != null &&
|
||||||
|
log.formattedDate!.isNotEmpty)
|
||||||
|
? DateTimeUtils.convertUtcToLocal(
|
||||||
|
log.formattedDate!,
|
||||||
|
format: 'd MMM yyyy',
|
||||||
|
)
|
||||||
|
: '-',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
MyText.bodySmall(
|
MyText.bodySmall(
|
||||||
"Time: ${log.formattedTime ?? '-'}",
|
log.formattedTime != null
|
||||||
|
? "Time: ${log.formattedTime}"
|
||||||
|
: "",
|
||||||
color: Colors.grey[700],
|
color: Colors.grey[700],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
const Divider(height: 1, color: Colors.grey),
|
||||||
|
// Middle Row: Image + Text (Done by, Description & Location)
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment:
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
if (log.latitude != null &&
|
// Image Column
|
||||||
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(width: 16),
|
|
||||||
if (log.thumbPreSignedUrl != null)
|
if (log.thumbPreSignedUrl != null)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@ -214,29 +159,146 @@ class AttendanceLogViewButton extends StatelessWidget {
|
|||||||
height: 60,
|
height: 60,
|
||||||
width: 60,
|
width: 60,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder:
|
errorBuilder: (_, __, ___) =>
|
||||||
(context, error, stackTrace) {
|
const Icon(Icons.broken_image,
|
||||||
return const Icon(Icons.broken_image,
|
size: 40, color: Colors.grey),
|
||||||
size: 20, 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
|
else
|
||||||
const Icon(Icons.broken_image,
|
MyText.bodySmall(
|
||||||
size: 20, color: Colors.grey),
|
"Description: No description provided",
|
||||||
|
fontWeight: 700,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
],
|
},
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -246,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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -276,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);
|
||||||
}
|
}
|
||||||
|
|||||||
106
lib/model/attendance/organization_per_project_list_model.dart
Normal file
106
lib/model/attendance/organization_per_project_list_model.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,11 +3,11 @@ 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;
|
||||||
@ -70,9 +70,11 @@ class _RegularizeActionButtonState extends State<RegularizeActionButton> {
|
|||||||
isUploading = true;
|
isUploading = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
widget.attendanceController.uploadingStates[widget.uniqueLogKey]?.value = true;
|
widget.attendanceController.uploadingStates[widget.uniqueLogKey]?.value =
|
||||||
|
true;
|
||||||
|
|
||||||
final success = await widget.attendanceController.captureAndUploadAttendance(
|
final success =
|
||||||
|
await widget.attendanceController.captureAndUploadAttendance(
|
||||||
widget.log.id,
|
widget.log.id,
|
||||||
widget.log.employeeId,
|
widget.log.employeeId,
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
@ -92,17 +94,18 @@ class _RegularizeActionButtonState extends State<RegularizeActionButton> {
|
|||||||
if (success) {
|
if (success) {
|
||||||
widget.attendanceController.fetchEmployeesByProject(selectedProjectId);
|
widget.attendanceController.fetchEmployeesByProject(selectedProjectId);
|
||||||
widget.attendanceController.fetchAttendanceLogs(selectedProjectId);
|
widget.attendanceController.fetchAttendanceLogs(selectedProjectId);
|
||||||
await widget.attendanceController.fetchRegularizationLogs(selectedProjectId);
|
await widget.attendanceController
|
||||||
|
.fetchRegularizationLogs(selectedProjectId);
|
||||||
await widget.attendanceController.fetchProjectData(selectedProjectId);
|
await widget.attendanceController.fetchProjectData(selectedProjectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
widget.attendanceController.uploadingStates[widget.uniqueLogKey]?.value = false;
|
widget.attendanceController.uploadingStates[widget.uniqueLogKey]?.value =
|
||||||
|
false;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
isUploading = false;
|
isUploading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -1,428 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:get/get.dart';
|
|
||||||
import 'package:marco/controller/task_planing/daily_task_planing_controller.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
|
||||||
import 'package:marco/controller/project_controller.dart';
|
|
||||||
|
|
||||||
class AssignTaskBottomSheet extends StatefulWidget {
|
|
||||||
final String workLocation;
|
|
||||||
final String activityName;
|
|
||||||
final int pendingTask;
|
|
||||||
final String workItemId;
|
|
||||||
final DateTime assignmentDate;
|
|
||||||
final String buildingName;
|
|
||||||
final String floorName;
|
|
||||||
final String workAreaName;
|
|
||||||
|
|
||||||
const AssignTaskBottomSheet({
|
|
||||||
super.key,
|
|
||||||
required this.buildingName,
|
|
||||||
required this.workLocation,
|
|
||||||
required this.floorName,
|
|
||||||
required this.workAreaName,
|
|
||||||
required this.activityName,
|
|
||||||
required this.pendingTask,
|
|
||||||
required this.workItemId,
|
|
||||||
required this.assignmentDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<AssignTaskBottomSheet> createState() => _AssignTaskBottomSheetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
|
|
||||||
final DailyTaskPlaningController controller = Get.find();
|
|
||||||
final ProjectController projectController = Get.find();
|
|
||||||
final TextEditingController targetController = TextEditingController();
|
|
||||||
final TextEditingController descriptionController = TextEditingController();
|
|
||||||
String? selectedProjectId;
|
|
||||||
|
|
||||||
final ScrollController _employeeListScrollController = ScrollController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_employeeListScrollController.dispose();
|
|
||||||
targetController.dispose();
|
|
||||||
descriptionController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
selectedProjectId = projectController.selectedProjectId.value;
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (selectedProjectId != null) {
|
|
||||||
controller.fetchEmployeesByProject(selectedProjectId!);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SafeArea(
|
|
||||||
child: Container(
|
|
||||||
padding: MediaQuery.of(context).viewInsets.add(MySpacing.all(16)),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
||||||
),
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.assignment, color: Colors.black54),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
MyText.titleMedium("Assign Task",
|
|
||||||
fontSize: 18, fontWeight: 600),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
onPressed: () => Get.back(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Divider(),
|
|
||||||
_infoRow(Icons.location_on, "Work Location",
|
|
||||||
"${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"),
|
|
||||||
Divider(),
|
|
||||||
_infoRow(Icons.pending_actions, "Pending Task of Activity",
|
|
||||||
"${widget.pendingTask}"),
|
|
||||||
Divider(),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
final RenderBox overlay = Overlay.of(context)
|
|
||||||
.context
|
|
||||||
.findRenderObject() as RenderBox;
|
|
||||||
final Size screenSize = overlay.size;
|
|
||||||
|
|
||||||
showMenu(
|
|
||||||
context: context,
|
|
||||||
position: RelativeRect.fromLTRB(
|
|
||||||
screenSize.width / 2 - 100,
|
|
||||||
screenSize.height / 2 - 20,
|
|
||||||
screenSize.width / 2 - 100,
|
|
||||||
screenSize.height / 2 - 20,
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: 'all',
|
|
||||||
child: Text("All Roles"),
|
|
||||||
),
|
|
||||||
...controller.roles.map((role) {
|
|
||||||
return PopupMenuItem(
|
|
||||||
value: role['id'].toString(),
|
|
||||||
child: Text(role['name'] ?? 'Unknown Role'),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
).then((value) {
|
|
||||||
if (value != null) {
|
|
||||||
controller.onRoleSelected(value == 'all' ? null : value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
MyText.titleMedium("Select Team :", fontWeight: 600),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Icon(Icons.filter_alt,
|
|
||||||
color: const Color.fromARGB(255, 95, 132, 255)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.height(8),
|
|
||||||
Container(
|
|
||||||
constraints: BoxConstraints(maxHeight: 150),
|
|
||||||
child: _buildEmployeeList(),
|
|
||||||
),
|
|
||||||
MySpacing.height(8),
|
|
||||||
Obx(() {
|
|
||||||
if (controller.selectedEmployees.isEmpty) return Container();
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
|
||||||
child: Wrap(
|
|
||||||
spacing: 4,
|
|
||||||
runSpacing: 4,
|
|
||||||
children: controller.selectedEmployees.map((e) {
|
|
||||||
return Obx(() {
|
|
||||||
final isSelected =
|
|
||||||
controller.uploadingStates[e.id]?.value ?? false;
|
|
||||||
if (!isSelected) return Container();
|
|
||||||
|
|
||||||
return Chip(
|
|
||||||
label: Text(e.name,
|
|
||||||
style: const TextStyle(color: Colors.white)),
|
|
||||||
backgroundColor:
|
|
||||||
const Color.fromARGB(255, 95, 132, 255),
|
|
||||||
deleteIcon:
|
|
||||||
const Icon(Icons.close, color: Colors.white),
|
|
||||||
onDeleted: () {
|
|
||||||
controller.uploadingStates[e.id]?.value = false;
|
|
||||||
controller.updateSelectedEmployees();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
_buildTextField(
|
|
||||||
icon: Icons.track_changes,
|
|
||||||
label: "Target for Today :",
|
|
||||||
controller: targetController,
|
|
||||||
hintText: "Enter target",
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
validatorType: "target",
|
|
||||||
),
|
|
||||||
MySpacing.height(24),
|
|
||||||
_buildTextField(
|
|
||||||
icon: Icons.description,
|
|
||||||
label: "Description :",
|
|
||||||
controller: descriptionController,
|
|
||||||
hintText: "Enter task description",
|
|
||||||
maxLines: 3,
|
|
||||||
validatorType: "description",
|
|
||||||
),
|
|
||||||
MySpacing.height(24),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => Get.back(),
|
|
||||||
icon: const Icon(Icons.close, color: Colors.red),
|
|
||||||
label: MyText.bodyMedium("Cancel", color: Colors.red),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
side: const BorderSide(color: Colors.red),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 20, vertical: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: _onAssignTaskPressed,
|
|
||||||
icon: const Icon(Icons.check_circle_outline,
|
|
||||||
color: Colors.white),
|
|
||||||
label:
|
|
||||||
MyText.bodyMedium("Assign Task", color: Colors.white),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.indigo,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 28, vertical: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmployeeList() {
|
|
||||||
return Obx(() {
|
|
||||||
if (controller.isLoading.value) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
final selectedRoleId = controller.selectedRoleId.value;
|
|
||||||
final filteredEmployees = selectedRoleId == null
|
|
||||||
? controller.employees
|
|
||||||
: controller.employees
|
|
||||||
.where((e) => e.jobRoleID.toString() == selectedRoleId)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (filteredEmployees.isEmpty) {
|
|
||||||
return const Text("No employees found for selected role.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scrollbar(
|
|
||||||
controller: _employeeListScrollController,
|
|
||||||
thumbVisibility: true,
|
|
||||||
interactive: true,
|
|
||||||
child: ListView.builder(
|
|
||||||
controller: _employeeListScrollController,
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
|
||||||
itemCount: filteredEmployees.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final employee = filteredEmployees[index];
|
|
||||||
final rxBool = controller.uploadingStates[employee.id];
|
|
||||||
return Obx(() => Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Theme(
|
|
||||||
data: Theme.of(context)
|
|
||||||
.copyWith(unselectedWidgetColor: Colors.black),
|
|
||||||
child: Checkbox(
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
side: const BorderSide(color: Colors.black),
|
|
||||||
),
|
|
||||||
value: rxBool?.value ?? false,
|
|
||||||
onChanged: (bool? selected) {
|
|
||||||
if (rxBool != null) {
|
|
||||||
rxBool.value = selected ?? false;
|
|
||||||
controller.updateSelectedEmployees();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fillColor:
|
|
||||||
WidgetStateProperty.resolveWith<Color>((states) {
|
|
||||||
if (states.contains(WidgetState.selected)) {
|
|
||||||
return const Color.fromARGB(255, 95, 132, 255);
|
|
||||||
}
|
|
||||||
return Colors.transparent;
|
|
||||||
}),
|
|
||||||
checkColor: Colors.white,
|
|
||||||
side: const BorderSide(color: Colors.black),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(employee.name,
|
|
||||||
style: TextStyle(fontSize: 14))),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTextField({
|
|
||||||
required IconData icon,
|
|
||||||
required String label,
|
|
||||||
required TextEditingController controller,
|
|
||||||
required String hintText,
|
|
||||||
TextInputType keyboardType = TextInputType.text,
|
|
||||||
int maxLines = 1,
|
|
||||||
required String validatorType,
|
|
||||||
}) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 18, color: Colors.black54),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
MyText.titleMedium(label, fontWeight: 600),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
MySpacing.height(6),
|
|
||||||
TextFormField(
|
|
||||||
controller: controller,
|
|
||||||
keyboardType: keyboardType,
|
|
||||||
maxLines: maxLines,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: hintText,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
validator: (value) => this
|
|
||||||
.controller
|
|
||||||
.formFieldValidator(value, fieldType: validatorType),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _infoRow(IconData icon, String title, String value) {
|
|
||||||
return Padding(
|
|
||||||
padding: MySpacing.y(6),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 20, color: Colors.grey[700]),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: RichText(
|
|
||||||
text: TextSpan(
|
|
||||||
children: [
|
|
||||||
WidgetSpan(
|
|
||||||
child: MyText.titleMedium("$title: ",
|
|
||||||
fontWeight: 600, color: Colors.black),
|
|
||||||
),
|
|
||||||
TextSpan(
|
|
||||||
text: value,
|
|
||||||
style: const TextStyle(color: Colors.black),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onAssignTaskPressed() {
|
|
||||||
final selectedTeam = controller.uploadingStates.entries
|
|
||||||
.where((e) => e.value.value)
|
|
||||||
.map((e) => e.key)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (selectedTeam.isEmpty) {
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Team Required",
|
|
||||||
message: "Please select at least one team member",
|
|
||||||
type: SnackbarType.error,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final target = int.tryParse(targetController.text.trim());
|
|
||||||
if (target == null || target <= 0) {
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Invalid Input",
|
|
||||||
message: "Please enter a valid target number",
|
|
||||||
type: SnackbarType.error,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target > widget.pendingTask) {
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Target Too High",
|
|
||||||
message:
|
|
||||||
"Target cannot be greater than pending task (${widget.pendingTask})",
|
|
||||||
type: SnackbarType.error,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final description = descriptionController.text.trim();
|
|
||||||
if (description.isEmpty) {
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Description Required",
|
|
||||||
message: "Please enter a description",
|
|
||||||
type: SnackbarType.error,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.assignDailyTask(
|
|
||||||
workItemId: widget.workItemId,
|
|
||||||
plannedTask: target,
|
|
||||||
description: description,
|
|
||||||
taskTeam: selectedTeam,
|
|
||||||
assignmentDate: widget.assignmentDate,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,875 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:get/get.dart';
|
|
||||||
import 'package:marco/controller/task_planing/report_task_controller.dart';
|
|
||||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_button.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
|
||||||
import 'package:marco/helpers/widgets/avatar.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_team_model_sheet.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'package:marco/model/dailyTaskPlaning/create_task_botom_sheet.dart';
|
|
||||||
|
|
||||||
class CommentTaskBottomSheet extends StatefulWidget {
|
|
||||||
final Map<String, dynamic> taskData;
|
|
||||||
final VoidCallback? onCommentSuccess;
|
|
||||||
final String taskDataId;
|
|
||||||
final String workAreaId;
|
|
||||||
final String activityId;
|
|
||||||
const CommentTaskBottomSheet({
|
|
||||||
super.key,
|
|
||||||
required this.taskData,
|
|
||||||
this.onCommentSuccess,
|
|
||||||
required this.taskDataId,
|
|
||||||
required this.workAreaId,
|
|
||||||
required this.activityId,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<CommentTaskBottomSheet> createState() => _CommentTaskBottomSheetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Member {
|
|
||||||
final String firstName;
|
|
||||||
_Member(this.firstName);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
|
|
||||||
with UIMixin {
|
|
||||||
late ReportTaskController controller;
|
|
||||||
final ScrollController _scrollController = ScrollController();
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
controller = Get.put(ReportTaskController(),
|
|
||||||
tag: widget.taskData['taskId'] ?? UniqueKey().toString());
|
|
||||||
final data = widget.taskData;
|
|
||||||
controller.basicValidator.getController('assigned_date')?.text =
|
|
||||||
data['assignedOn'] ?? '';
|
|
||||||
controller.basicValidator.getController('assigned_by')?.text =
|
|
||||||
data['assignedBy'] ?? '';
|
|
||||||
controller.basicValidator.getController('work_area')?.text =
|
|
||||||
data['location'] ?? '';
|
|
||||||
controller.basicValidator.getController('activity')?.text =
|
|
||||||
data['activity'] ?? '';
|
|
||||||
controller.basicValidator.getController('planned_work')?.text =
|
|
||||||
data['plannedWork'] ?? '';
|
|
||||||
controller.basicValidator.getController('completed_work')?.text =
|
|
||||||
data['completedWork'] ?? '';
|
|
||||||
controller.basicValidator.getController('team_members')?.text =
|
|
||||||
(data['teamMembers'] as List<dynamic>).join(', ');
|
|
||||||
controller.basicValidator.getController('assigned')?.text =
|
|
||||||
data['assigned'] ?? '';
|
|
||||||
controller.basicValidator.getController('task_id')?.text =
|
|
||||||
data['taskId'] ?? '';
|
|
||||||
controller.basicValidator.getController('comment')?.clear();
|
|
||||||
controller.selectedImages.clear();
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (_scrollController.hasClients) {
|
|
||||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
String timeAgo(String dateString) {
|
|
||||||
try {
|
|
||||||
DateTime date = DateTime.parse(dateString + "Z").toLocal();
|
|
||||||
final now = DateTime.now();
|
|
||||||
final difference = now.difference(date);
|
|
||||||
if (difference.inDays > 8) {
|
|
||||||
return DateFormat('dd-MM-yyyy').format(date);
|
|
||||||
} else if (difference.inDays >= 1) {
|
|
||||||
return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago';
|
|
||||||
} else if (difference.inHours >= 1) {
|
|
||||||
return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago';
|
|
||||||
} else if (difference.inMinutes >= 1) {
|
|
||||||
return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago';
|
|
||||||
} else {
|
|
||||||
return 'just now';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error parsing date: $e');
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
|
||||||
),
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
|
||||||
left: 24,
|
|
||||||
right: 24,
|
|
||||||
top: 12,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// Drag handle
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 4,
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade400,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GetBuilder<ReportTaskController>(
|
|
||||||
tag: widget.taskData['taskId'] ?? '',
|
|
||||||
builder: (controller) {
|
|
||||||
return Form(
|
|
||||||
key: controller.basicValidator.formKey,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
MySpacing.height(12),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
MyText.titleMedium(
|
|
||||||
"Comment Task",
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// Second row: Right-aligned "+ Create Task" button
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
InkWell(
|
|
||||||
onTap: () {
|
|
||||||
showCreateTaskBottomSheet(
|
|
||||||
workArea:
|
|
||||||
widget.taskData['location'] ?? '',
|
|
||||||
activity:
|
|
||||||
widget.taskData['activity'] ?? '',
|
|
||||||
completedWork:
|
|
||||||
widget.taskData['completedWork'] ??
|
|
||||||
'',
|
|
||||||
unit: widget.taskData['unit'] ?? '',
|
|
||||||
onCategoryChanged: (category) {
|
|
||||||
debugPrint(
|
|
||||||
"Category changed to: $category");
|
|
||||||
},
|
|
||||||
parentTaskId: widget.taskDataId,
|
|
||||||
plannedTask: int.tryParse(
|
|
||||||
widget.taskData['plannedWork'] ??
|
|
||||||
'0') ??
|
|
||||||
0,
|
|
||||||
activityId: widget.activityId,
|
|
||||||
workAreaId: widget.workAreaId,
|
|
||||||
onSubmit: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.blueAccent.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: MyText.bodySmall(
|
|
||||||
"+ Create Task",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: Colors.blueAccent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
buildRow(
|
|
||||||
"Assigned By",
|
|
||||||
controller.basicValidator
|
|
||||||
.getController('assigned_by')
|
|
||||||
?.text
|
|
||||||
.trim(),
|
|
||||||
icon: Icons.person_outline,
|
|
||||||
),
|
|
||||||
buildRow(
|
|
||||||
"Work Area",
|
|
||||||
controller.basicValidator
|
|
||||||
.getController('work_area')
|
|
||||||
?.text
|
|
||||||
.trim(),
|
|
||||||
icon: Icons.place_outlined,
|
|
||||||
),
|
|
||||||
buildRow(
|
|
||||||
"Activity",
|
|
||||||
controller.basicValidator
|
|
||||||
.getController('activity')
|
|
||||||
?.text
|
|
||||||
.trim(),
|
|
||||||
icon: Icons.assignment_outlined,
|
|
||||||
),
|
|
||||||
buildRow(
|
|
||||||
"Planned Work",
|
|
||||||
controller.basicValidator
|
|
||||||
.getController('planned_work')
|
|
||||||
?.text
|
|
||||||
.trim(),
|
|
||||||
icon: Icons.schedule_outlined,
|
|
||||||
),
|
|
||||||
buildRow(
|
|
||||||
"Completed Work",
|
|
||||||
controller.basicValidator
|
|
||||||
.getController('completed_work')
|
|
||||||
?.text
|
|
||||||
.trim(),
|
|
||||||
icon: Icons.done_all_outlined,
|
|
||||||
),
|
|
||||||
buildTeamMembers(),
|
|
||||||
if ((widget.taskData['reportedPreSignedUrls']
|
|
||||||
as List<dynamic>?)
|
|
||||||
?.isNotEmpty ==
|
|
||||||
true)
|
|
||||||
buildReportedImagesSection(
|
|
||||||
imageUrls: List<String>.from(
|
|
||||||
widget.taskData['reportedPreSignedUrls'] ?? []),
|
|
||||||
context: context,
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.comment_outlined,
|
|
||||||
size: 18, color: Colors.grey[700]),
|
|
||||||
MySpacing.width(8),
|
|
||||||
MyText.titleSmall(
|
|
||||||
"Comment:",
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
MySpacing.height(8),
|
|
||||||
TextFormField(
|
|
||||||
validator: controller.basicValidator
|
|
||||||
.getValidation('comment'),
|
|
||||||
controller: controller.basicValidator
|
|
||||||
.getController('comment'),
|
|
||||||
keyboardType: TextInputType.text,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: "eg: Work done successfully",
|
|
||||||
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
|
||||||
border: outlineInputBorder,
|
|
||||||
enabledBorder: outlineInputBorder,
|
|
||||||
focusedBorder: focusedInputBorder,
|
|
||||||
contentPadding: MySpacing.all(16),
|
|
||||||
isCollapsed: true,
|
|
||||||
floatingLabelBehavior: FloatingLabelBehavior.never,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.height(16),
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.camera_alt_outlined,
|
|
||||||
size: 18, color: Colors.grey[700]),
|
|
||||||
MySpacing.width(8),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MyText.titleSmall("Attach Photos:",
|
|
||||||
fontWeight: 600),
|
|
||||||
MySpacing.height(12),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Obx(() {
|
|
||||||
final images = controller.selectedImages;
|
|
||||||
|
|
||||||
return buildImagePickerSection(
|
|
||||||
images: images,
|
|
||||||
onCameraTap: () =>
|
|
||||||
controller.pickImages(fromCamera: true),
|
|
||||||
onUploadTap: () =>
|
|
||||||
controller.pickImages(fromCamera: false),
|
|
||||||
onRemoveImage: (index) =>
|
|
||||||
controller.removeImageAt(index),
|
|
||||||
onPreviewImage: (index) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => ImageViewerDialog(
|
|
||||||
imageSources: images,
|
|
||||||
initialIndex: index,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
MySpacing.height(24),
|
|
||||||
buildCommentActionButtons(
|
|
||||||
onCancel: () => Navigator.of(context).pop(),
|
|
||||||
onSubmit: () async {
|
|
||||||
if (controller.basicValidator.validateForm()) {
|
|
||||||
await controller.commentTask(
|
|
||||||
projectId: controller.basicValidator
|
|
||||||
.getController('task_id')
|
|
||||||
?.text ??
|
|
||||||
'',
|
|
||||||
comment: controller.basicValidator
|
|
||||||
.getController('comment')
|
|
||||||
?.text ??
|
|
||||||
'',
|
|
||||||
images: controller.selectedImages,
|
|
||||||
);
|
|
||||||
if (widget.onCommentSuccess != null) {
|
|
||||||
widget.onCommentSuccess!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isLoading: controller.isLoading,
|
|
||||||
),
|
|
||||||
MySpacing.height(10),
|
|
||||||
if ((widget.taskData['taskComments'] as List<dynamic>?)
|
|
||||||
?.isNotEmpty ==
|
|
||||||
true) ...[
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
MySpacing.width(10),
|
|
||||||
Icon(Icons.chat_bubble_outline,
|
|
||||||
size: 18, color: Colors.grey[700]),
|
|
||||||
MySpacing.width(8),
|
|
||||||
MyText.titleSmall(
|
|
||||||
"Comments",
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
MySpacing.height(12),
|
|
||||||
Builder(
|
|
||||||
builder: (context) {
|
|
||||||
final comments = List<Map<String, dynamic>>.from(
|
|
||||||
widget.taskData['taskComments'] as List,
|
|
||||||
);
|
|
||||||
return buildCommentList(comments, context);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildReportedImagesSection({
|
|
||||||
required List<String> imageUrls,
|
|
||||||
required BuildContext context,
|
|
||||||
String title = "Reported Images",
|
|
||||||
}) {
|
|
||||||
if (imageUrls.isEmpty) return const SizedBox();
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MySpacing.height(8),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 0.0),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]),
|
|
||||||
MySpacing.width(8),
|
|
||||||
MyText.titleSmall(
|
|
||||||
title,
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.height(8),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
||||||
child: SizedBox(
|
|
||||||
height: 70,
|
|
||||||
child: ListView.separated(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
itemCount: imageUrls.length,
|
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final url = imageUrls[index];
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
barrierColor: Colors.black54,
|
|
||||||
builder: (_) => ImageViewerDialog(
|
|
||||||
imageSources: imageUrls,
|
|
||||||
initialIndex: index,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Image.network(
|
|
||||||
url,
|
|
||||||
width: 70,
|
|
||||||
height: 70,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
errorBuilder: (context, error, stackTrace) => Container(
|
|
||||||
width: 70,
|
|
||||||
height: 70,
|
|
||||||
color: Colors.grey.shade200,
|
|
||||||
child:
|
|
||||||
Icon(Icons.broken_image, color: Colors.grey[600]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.height(16),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildTeamMembers() {
|
|
||||||
final teamMembersText =
|
|
||||||
controller.basicValidator.getController('team_members')?.text ?? '';
|
|
||||||
final members = teamMembersText
|
|
||||||
.split(',')
|
|
||||||
.map((e) => e.trim())
|
|
||||||
.where((e) => e.isNotEmpty)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
MyText.titleSmall(
|
|
||||||
"Team Members:",
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
MySpacing.width(12),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
TeamBottomSheet.show(
|
|
||||||
context: context,
|
|
||||||
teamMembers: members.map((name) => _Member(name)).toList(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: SizedBox(
|
|
||||||
height: 32,
|
|
||||||
width: 100,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
for (int i = 0; i < members.length.clamp(0, 3); i++)
|
|
||||||
Positioned(
|
|
||||||
left: i * 24.0,
|
|
||||||
child: Tooltip(
|
|
||||||
message: members[i],
|
|
||||||
child: Avatar(
|
|
||||||
firstName: members[i],
|
|
||||||
lastName: '',
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (members.length > 3)
|
|
||||||
Positioned(
|
|
||||||
left: 2 * 24.0,
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 16,
|
|
||||||
backgroundColor: Colors.grey.shade300,
|
|
||||||
child: MyText.bodyMedium(
|
|
||||||
'+${members.length - 3}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12, color: Colors.black87),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildCommentActionButtons({
|
|
||||||
required VoidCallback onCancel,
|
|
||||||
required Future<void> Function() onSubmit,
|
|
||||||
required RxBool isLoading,
|
|
||||||
double? buttonHeight,
|
|
||||||
}) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: onCancel,
|
|
||||||
icon: const Icon(Icons.close, color: Colors.red, size: 18),
|
|
||||||
label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
side: const BorderSide(color: Colors.red),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Obx(() {
|
|
||||||
return ElevatedButton.icon(
|
|
||||||
onPressed: isLoading.value ? null : () => onSubmit(),
|
|
||||||
icon: isLoading.value
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.check_circle_outline, color: Colors.white, size: 18),
|
|
||||||
label: isLoading.value
|
|
||||||
? const SizedBox()
|
|
||||||
: MyText.bodyMedium("Comment", color: Colors.white, fontWeight: 600),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.indigo,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildRow(String label, String? value, {IconData? icon}) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (icon != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8.0, top: 2),
|
|
||||||
child: Icon(icon, size: 18, color: Colors.grey[700]),
|
|
||||||
),
|
|
||||||
MyText.titleSmall(
|
|
||||||
"$label:",
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
MySpacing.width(12),
|
|
||||||
Expanded(
|
|
||||||
child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildCommentList(
|
|
||||||
List<Map<String, dynamic>> comments, BuildContext context) {
|
|
||||||
comments.sort((a, b) {
|
|
||||||
final aDate = DateTime.tryParse(a['date'] ?? '') ??
|
|
||||||
DateTime.fromMillisecondsSinceEpoch(0);
|
|
||||||
final bDate = DateTime.tryParse(b['date'] ?? '') ??
|
|
||||||
DateTime.fromMillisecondsSinceEpoch(0);
|
|
||||||
return bDate.compareTo(aDate); // newest first
|
|
||||||
});
|
|
||||||
|
|
||||||
return SizedBox(
|
|
||||||
height: 300,
|
|
||||||
child: ListView.builder(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
itemCount: comments.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final comment = comments[index];
|
|
||||||
final commentText = comment['text'] ?? '-';
|
|
||||||
final commentedBy = comment['commentedBy'] ?? 'Unknown';
|
|
||||||
final relativeTime = timeAgo(comment['date'] ?? '');
|
|
||||||
final imageUrls = List<String>.from(comment['preSignedUrls'] ?? []);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade200,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Avatar(
|
|
||||||
firstName: commentedBy.split(' ').first,
|
|
||||||
lastName: commentedBy.split(' ').length > 1
|
|
||||||
? commentedBy.split(' ').last
|
|
||||||
: '',
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
MyText.bodyMedium(
|
|
||||||
commentedBy,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
MyText.bodySmall(
|
|
||||||
relativeTime,
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.black54,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: MyText.bodyMedium(
|
|
||||||
commentText,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
if (imageUrls.isNotEmpty) ...[
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.attach_file_outlined,
|
|
||||||
size: 18, color: Colors.grey[700]),
|
|
||||||
MyText.bodyMedium(
|
|
||||||
'Attachments',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
SizedBox(
|
|
||||||
height: 60,
|
|
||||||
child: ListView.separated(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
itemCount: imageUrls.length,
|
|
||||||
itemBuilder: (context, imageIndex) {
|
|
||||||
final imageUrl = imageUrls[imageIndex];
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
barrierColor: Colors.black54,
|
|
||||||
builder: (_) => ImageViewerDialog(
|
|
||||||
imageSources: imageUrls,
|
|
||||||
initialIndex: imageIndex,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 60,
|
|
||||||
height: 60,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
color: Colors.grey[100],
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black26,
|
|
||||||
blurRadius: 6,
|
|
||||||
offset: Offset(2, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Image.network(
|
|
||||||
imageUrl,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
errorBuilder:
|
|
||||||
(context, error, stackTrace) =>
|
|
||||||
Container(
|
|
||||||
color: Colors.grey[300],
|
|
||||||
child: Icon(Icons.broken_image,
|
|
||||||
color: Colors.grey[700]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Positioned(
|
|
||||||
right: 4,
|
|
||||||
bottom: 4,
|
|
||||||
child: Icon(Icons.zoom_in,
|
|
||||||
color: Colors.white70, size: 16),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
separatorBuilder: (_, __) =>
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildImagePickerSection({
|
|
||||||
required List<File> images,
|
|
||||||
required VoidCallback onCameraTap,
|
|
||||||
required VoidCallback onUploadTap,
|
|
||||||
required void Function(int index) onRemoveImage,
|
|
||||||
required void Function(int initialIndex) onPreviewImage,
|
|
||||||
}) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (images.isEmpty)
|
|
||||||
Container(
|
|
||||||
height: 70,
|
|
||||||
width: double.infinity,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: Colors.grey.shade300, width: 2),
|
|
||||||
color: Colors.grey.shade100,
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Icon(Icons.photo_camera_outlined,
|
|
||||||
size: 48, color: Colors.grey.shade400),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
SizedBox(
|
|
||||||
height: 70,
|
|
||||||
child: ListView.separated(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
itemCount: images.length,
|
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final file = images[index];
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => onPreviewImage(index),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Image.file(
|
|
||||||
file,
|
|
||||||
height: 70,
|
|
||||||
width: 70,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: 4,
|
|
||||||
right: 4,
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () => onRemoveImage(index),
|
|
||||||
child: Container(
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Colors.black54,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.close,
|
|
||||||
size: 20, color: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.height(16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: MyButton.outlined(
|
|
||||||
onPressed: onCameraTap,
|
|
||||||
padding: MySpacing.xy(12, 10),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.camera_alt,
|
|
||||||
size: 16, color: Colors.blueAccent),
|
|
||||||
MySpacing.width(6),
|
|
||||||
MyText.bodySmall('Capture', color: Colors.blueAccent),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.width(12),
|
|
||||||
Expanded(
|
|
||||||
child: MyButton.outlined(
|
|
||||||
onPressed: onUploadTap,
|
|
||||||
padding: MySpacing.xy(12, 10),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.upload_file,
|
|
||||||
size: 16, color: Colors.blueAccent),
|
|
||||||
MySpacing.width(6),
|
|
||||||
MyText.bodySmall('Upload', color: Colors.blueAccent),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,291 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:get/get.dart';
|
|
||||||
import 'package:marco/controller/task_planing/add_task_controller.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
|
||||||
|
|
||||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
void showCreateTaskBottomSheet({
|
|
||||||
required String workArea,
|
|
||||||
required String activity,
|
|
||||||
required String completedWork,
|
|
||||||
required String unit,
|
|
||||||
required Function(String) onCategoryChanged,
|
|
||||||
required String parentTaskId,
|
|
||||||
required int plannedTask,
|
|
||||||
required String activityId,
|
|
||||||
required String workAreaId,
|
|
||||||
required VoidCallback onSubmit,
|
|
||||||
}) {
|
|
||||||
final controller = Get.put(AddTaskController());
|
|
||||||
final TextEditingController plannedTaskController =
|
|
||||||
TextEditingController(text: plannedTask.toString());
|
|
||||||
final TextEditingController descriptionController = TextEditingController();
|
|
||||||
|
|
||||||
Get.bottomSheet(
|
|
||||||
StatefulBuilder(
|
|
||||||
builder: (context, setState) {
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final isLarge = constraints.maxWidth > 600;
|
|
||||||
final horizontalPadding =
|
|
||||||
isLarge ? constraints.maxWidth * 0.2 : 16.0;
|
|
||||||
|
|
||||||
return // Inside showManageTaskBottomSheet...
|
|
||||||
|
|
||||||
SafeArea(
|
|
||||||
child: Material(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius:
|
|
||||||
const BorderRadius.vertical(top: Radius.circular(20)),
|
|
||||||
child: Container(
|
|
||||||
constraints: const BoxConstraints(maxHeight: 760),
|
|
||||||
padding: EdgeInsets.fromLTRB(
|
|
||||||
horizontalPadding, 12, horizontalPadding, 24),
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Center(
|
|
||||||
child: Container(
|
|
||||||
width: 40,
|
|
||||||
height: 4,
|
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade400,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Center(
|
|
||||||
child: MyText.titleLarge(
|
|
||||||
"Create Task",
|
|
||||||
fontWeight: 700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
_infoCardSection([
|
|
||||||
_infoRowWithIcon(
|
|
||||||
Icons.workspaces, "Selected Work Area", workArea),
|
|
||||||
_infoRowWithIcon(
|
|
||||||
Icons.list_alt, "Selected Activity", activity),
|
|
||||||
_infoRowWithIcon(Icons.check_circle_outline,
|
|
||||||
"Completed Work", completedWork),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_sectionTitle(Icons.edit_calendar, "Planned Work"),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_customTextField(
|
|
||||||
controller: plannedTaskController,
|
|
||||||
hint: "Enter planned work",
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_sectionTitle(Icons.description_outlined, "Comment"),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_customTextField(
|
|
||||||
controller: descriptionController,
|
|
||||||
hint: "Enter task description",
|
|
||||||
maxLines: 3,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_sectionTitle(
|
|
||||||
Icons.category_outlined, "Selected Work Category"),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Obx(() {
|
|
||||||
final categoryMap = controller.categoryIdNameMap;
|
|
||||||
final String selectedName =
|
|
||||||
controller.selectedCategoryId.value != null
|
|
||||||
? (categoryMap[controller
|
|
||||||
.selectedCategoryId.value!] ??
|
|
||||||
'Select Category')
|
|
||||||
: 'Select Category';
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12, vertical: 14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade100,
|
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: PopupMenuButton<String>(
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
onSelected: (val) {
|
|
||||||
controller.selectCategory(val);
|
|
||||||
onCategoryChanged(val);
|
|
||||||
},
|
|
||||||
itemBuilder: (context) => categoryMap.entries
|
|
||||||
.map(
|
|
||||||
(entry) => PopupMenuItem<String>(
|
|
||||||
value: entry.key,
|
|
||||||
child: Text(entry.value),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment:
|
|
||||||
MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
selectedName,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14, color: Colors.black87),
|
|
||||||
),
|
|
||||||
const Icon(Icons.arrow_drop_down),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: () => Get.back(),
|
|
||||||
icon: const Icon(Icons.close, size: 18),
|
|
||||||
label: MyText.bodyMedium("Cancel",
|
|
||||||
fontWeight: 600),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
side: const BorderSide(color: Colors.grey),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () async {
|
|
||||||
final plannedValue = int.tryParse(
|
|
||||||
plannedTaskController.text.trim()) ??
|
|
||||||
0;
|
|
||||||
final comment =
|
|
||||||
descriptionController.text.trim();
|
|
||||||
final selectedCategoryId =
|
|
||||||
controller.selectedCategoryId.value;
|
|
||||||
if (selectedCategoryId == null) {
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "error",
|
|
||||||
message: "Please select a work category!",
|
|
||||||
type: SnackbarType.error,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final success = await controller.createTask(
|
|
||||||
parentTaskId: parentTaskId,
|
|
||||||
plannedTask: plannedValue,
|
|
||||||
comment: comment,
|
|
||||||
workAreaId: workAreaId,
|
|
||||||
activityId: activityId,
|
|
||||||
categoryId: selectedCategoryId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
Get.back();
|
|
||||||
Future.delayed(
|
|
||||||
const Duration(milliseconds: 300), () {
|
|
||||||
onSubmit();
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Success",
|
|
||||||
message: "Task created successfully!",
|
|
||||||
type: SnackbarType.success,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.check, size: 18),
|
|
||||||
label: MyText.bodyMedium("Submit",
|
|
||||||
color: Colors.white, fontWeight: 600),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.blueAccent,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
isScrollControlled: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _sectionTitle(IconData icon, String title) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: Colors.grey[700], size: 18),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
MyText.bodyMedium(title, fontWeight: 600),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _customTextField({
|
|
||||||
required TextEditingController controller,
|
|
||||||
required String hint,
|
|
||||||
int maxLines = 1,
|
|
||||||
TextInputType keyboardType = TextInputType.text,
|
|
||||||
}) {
|
|
||||||
return TextField(
|
|
||||||
controller: controller,
|
|
||||||
maxLines: maxLines,
|
|
||||||
keyboardType: keyboardType,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: hint,
|
|
||||||
filled: true,
|
|
||||||
fillColor: Colors.grey.shade100,
|
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _infoCardSection(List<Widget> children) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade100,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
child: Column(children: children),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _infoRowWithIcon(IconData icon, String title, String value) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: Colors.grey[700], size: 18),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MyText.bodyMedium(title, fontWeight: 600),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
MyText.bodySmall(value, color: Colors.grey[800]),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:marco/controller/permission_controller.dart';
|
|
||||||
import 'package:marco/controller/dashboard/daily_task_controller.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
|
||||||
|
|
||||||
class DailyProgressReportFilter extends StatefulWidget {
|
|
||||||
final DailyTaskController controller;
|
|
||||||
final PermissionController permissionController;
|
|
||||||
|
|
||||||
const DailyProgressReportFilter({
|
|
||||||
super.key,
|
|
||||||
required this.controller,
|
|
||||||
required this.permissionController,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<DailyProgressReportFilter> createState() =>
|
|
||||||
_DailyProgressReportFilterState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DailyProgressReportFilterState extends State<DailyProgressReportFilter> {
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
}
|
|
||||||
|
|
||||||
String getLabelText() {
|
|
||||||
final startDate = widget.controller.startDateTask;
|
|
||||||
final endDate = widget.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 SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
|
||||||
),
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 12, bottom: 8),
|
|
||||||
child: Center(
|
|
||||||
child: Container(
|
|
||||||
width: 40,
|
|
||||||
height: 4,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[400],
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: MyText.titleSmall(
|
|
||||||
"Select Date Range",
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
onTap: () => widget.controller.selectDateRangeForTaskData(
|
|
||||||
context,
|
|
||||||
widget.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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
Padding(
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
child: SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Text('Apply Filter'),
|
|
||||||
onPressed: () {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
Navigator.pop(context, {
|
|
||||||
'startDate': widget.controller.startDateTask,
|
|
||||||
'endDate': widget.controller.endDateTask,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user