Vaibhav_Feature-#768 #59
@ -5,40 +5,73 @@ plugins {
|
|||||||
id "dev.flutter.flutter-gradle-plugin"
|
id "dev.flutter.flutter-gradle-plugin"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.aiotstage"
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.aiotstage"
|
||||||
// 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 = flutter.minSdkVersion
|
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 = "../.."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,11 +1,13 @@
|
|||||||
<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.WRITE_CONTACTS"/>
|
||||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package com.example.marco
|
package com.marco.aiotstage
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
|||||||
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.aiotstage;
|
||||||
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.aiotstage.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.aiotstage.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.aiotstage.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.aiotstage;
|
||||||
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.aiotstage;
|
||||||
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;
|
||||||
|
|||||||
@ -10,14 +10,17 @@ import 'package:marco/helpers/services/app_logger.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 +31,27 @@ 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 +61,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 +84,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 +107,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,6 +135,7 @@ class MPINController extends GetxController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Navigate to dashboard
|
||||||
void _navigateToDashboard({String? message}) {
|
void _navigateToDashboard({String? message}) {
|
||||||
if (message != null) {
|
if (message != null) {
|
||||||
logSafe("Navigating to Dashboard with message: $message");
|
logSafe("Navigating to Dashboard with message: $message");
|
||||||
@ -127,6 +148,7 @@ class MPINController extends GetxController {
|
|||||||
Get.offAll(() => const DashboardScreen());
|
Get.offAll(() => const DashboardScreen());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 +157,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 +166,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 +185,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 +200,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 +210,11 @@ 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,19 +226,20 @@ 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", );
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -278,6 +292,7 @@ class MPINController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Increment failed attempts and warn
|
||||||
void onInvalidMPIN() {
|
void onInvalidMPIN() {
|
||||||
failedAttempts.value++;
|
failedAttempts.value++;
|
||||||
if (failedAttempts.value >= 3) {
|
if (failedAttempts.value >= 3) {
|
||||||
|
|||||||
@ -110,7 +110,8 @@ class AddEmployeeController extends MyController {
|
|||||||
logSafe("Failed to fetch roles: null result", level: LogLevel.error);
|
logSafe("Failed to fetch roles: null result", level: LogLevel.error);
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
logSafe("Error fetching roles", level: LogLevel.error, error: e, stackTrace: st);
|
logSafe("Error fetching roles",
|
||||||
|
level: LogLevel.error, error: e, stackTrace: st);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,7 +121,7 @@ class AddEmployeeController extends MyController {
|
|||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> createEmployees() async {
|
Future<Map<String, dynamic>?> createEmployees() async {
|
||||||
logSafe("Starting employee creation...");
|
logSafe("Starting employee creation...");
|
||||||
if (selectedGender == null || selectedRoleId == null) {
|
if (selectedGender == null || selectedRoleId == null) {
|
||||||
logSafe("Missing gender or role.", level: LogLevel.warning);
|
logSafe("Missing gender or role.", level: LogLevel.warning);
|
||||||
@ -129,14 +130,13 @@ class AddEmployeeController extends MyController {
|
|||||||
message: "Please select both Gender and Role.",
|
message: "Please select both Gender and Role.",
|
||||||
type: SnackbarType.warning,
|
type: SnackbarType.warning,
|
||||||
);
|
);
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final firstName = basicValidator.getController("first_name")?.text.trim();
|
final firstName = basicValidator.getController("first_name")?.text.trim();
|
||||||
final lastName = basicValidator.getController("last_name")?.text.trim();
|
final lastName = basicValidator.getController("last_name")?.text.trim();
|
||||||
final phoneNumber = basicValidator.getController("phone_number")?.text.trim();
|
final phoneNumber =
|
||||||
|
basicValidator.getController("phone_number")?.text.trim();
|
||||||
logSafe("Creating employee", level: LogLevel.info);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await ApiService.createEmployee(
|
final response = await ApiService.createEmployee(
|
||||||
@ -146,20 +146,24 @@ class AddEmployeeController extends MyController {
|
|||||||
gender: selectedGender!.name,
|
gender: selectedGender!.name,
|
||||||
jobRoleId: selectedRoleId!,
|
jobRoleId: selectedRoleId!,
|
||||||
);
|
);
|
||||||
logSafe("Response: $response");
|
|
||||||
if (response == true) {
|
logSafe("Response: $response");
|
||||||
|
|
||||||
|
if (response != null && response['success'] == true) {
|
||||||
logSafe("Employee created successfully.");
|
logSafe("Employee created successfully.");
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Success",
|
title: "Success",
|
||||||
message: "Employee created successfully!",
|
message: "Employee created successfully!",
|
||||||
type: SnackbarType.success,
|
type: SnackbarType.success,
|
||||||
);
|
);
|
||||||
return true;
|
return response;
|
||||||
} else {
|
} else {
|
||||||
logSafe("Failed to create employee (response false)", level: LogLevel.error);
|
logSafe("Failed to create employee (response false)",
|
||||||
|
level: LogLevel.error);
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
logSafe("Error creating employee", level: LogLevel.error, error: e, stackTrace: st);
|
logSafe("Error creating employee",
|
||||||
|
level: LogLevel.error, error: e, stackTrace: st);
|
||||||
}
|
}
|
||||||
|
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
@ -167,7 +171,7 @@ logSafe("Response: $response");
|
|||||||
message: "Failed to create employee.",
|
message: "Failed to create employee.",
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _checkAndRequestContactsPermission() async {
|
Future<bool> _checkAndRequestContactsPermission() async {
|
||||||
@ -181,7 +185,8 @@ logSafe("Response: $response");
|
|||||||
|
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Permission Required",
|
title: "Permission Required",
|
||||||
message: "Please allow Contacts permission from settings to pick a contact.",
|
message:
|
||||||
|
"Please allow Contacts permission from settings to pick a contact.",
|
||||||
type: SnackbarType.warning,
|
type: SnackbarType.warning,
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
@ -195,7 +200,8 @@ logSafe("Response: $response");
|
|||||||
final picked = await FlutterContacts.openExternalPick();
|
final picked = await FlutterContacts.openExternalPick();
|
||||||
if (picked == null) return;
|
if (picked == null) return;
|
||||||
|
|
||||||
final contact = await FlutterContacts.getContact(picked.id, withProperties: true);
|
final contact =
|
||||||
|
await FlutterContacts.getContact(picked.id, withProperties: true);
|
||||||
if (contact == null) {
|
if (contact == null) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Error",
|
title: "Error",
|
||||||
@ -216,7 +222,8 @@ logSafe("Response: $response");
|
|||||||
|
|
||||||
final indiaPhones = contact.phones.where((p) {
|
final indiaPhones = contact.phones.where((p) {
|
||||||
final normalized = p.number.replaceAll(RegExp(r'[^0-9+]'), '');
|
final normalized = p.number.replaceAll(RegExp(r'[^0-9+]'), '');
|
||||||
return normalized.startsWith('+91') || RegExp(r'^\d{10}$').hasMatch(normalized);
|
return normalized.startsWith('+91') ||
|
||||||
|
RegExp(r'^\d{10}$').hasMatch(normalized);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
if (indiaPhones.isEmpty) {
|
if (indiaPhones.isEmpty) {
|
||||||
@ -256,10 +263,12 @@ logSafe("Response: $response");
|
|||||||
? normalizedPhone.substring(normalizedPhone.length - 10)
|
? normalizedPhone.substring(normalizedPhone.length - 10)
|
||||||
: normalizedPhone;
|
: normalizedPhone;
|
||||||
|
|
||||||
basicValidator.getController('phone_number')?.text = phoneWithoutCountryCode;
|
basicValidator.getController('phone_number')?.text =
|
||||||
|
phoneWithoutCountryCode;
|
||||||
update();
|
update();
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
logSafe("Error fetching contacts", level: LogLevel.error, error: e, stackTrace: st);
|
logSafe("Error fetching contacts",
|
||||||
|
level: LogLevel.error, error: e, stackTrace: st);
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Error",
|
title: "Error",
|
||||||
message: "Failed to fetch contacts.",
|
message: "Failed to fetch contacts.",
|
||||||
|
|||||||
@ -1,22 +1,25 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.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/helpers/widgets/my_image_compressor.dart';
|
import 'package:marco/helpers/widgets/my_image_compressor.dart';
|
||||||
|
|
||||||
import 'package:marco/model/attendance_model.dart';
|
import 'package:marco/model/attendance_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/employee_model.dart';
|
||||||
import 'package:marco/model/attendance_log_model.dart';
|
import 'package:marco/model/attendance_log_model.dart';
|
||||||
import 'package:marco/model/regularization_log_model.dart';
|
import 'package:marco/model/regularization_log_model.dart';
|
||||||
import 'package:marco/model/attendance_log_view_model.dart';
|
import 'package:marco/model/attendance_log_view_model.dart';
|
||||||
|
|
||||||
import 'package:marco/controller/project_controller.dart';
|
import 'package:marco/controller/project_controller.dart';
|
||||||
|
|
||||||
class AttendanceController extends GetxController {
|
class AttendanceController extends GetxController {
|
||||||
|
// Data models
|
||||||
List<AttendanceModel> attendances = [];
|
List<AttendanceModel> attendances = [];
|
||||||
List<ProjectModel> projects = [];
|
List<ProjectModel> projects = [];
|
||||||
List<EmployeeModel> employees = [];
|
List<EmployeeModel> employees = [];
|
||||||
@ -24,19 +27,18 @@ class AttendanceController extends GetxController {
|
|||||||
List<RegularizationLogModel> regularizationLogs = [];
|
List<RegularizationLogModel> regularizationLogs = [];
|
||||||
List<AttendanceLogViewModel> attendenceLogsView = [];
|
List<AttendanceLogViewModel> attendenceLogsView = [];
|
||||||
|
|
||||||
|
// States
|
||||||
String selectedTab = 'Employee List';
|
String selectedTab = 'Employee List';
|
||||||
|
|
||||||
DateTime? startDateAttendance;
|
DateTime? startDateAttendance;
|
||||||
DateTime? endDateAttendance;
|
DateTime? endDateAttendance;
|
||||||
|
|
||||||
RxBool isLoading = true.obs;
|
final isLoading = true.obs;
|
||||||
RxBool isLoadingProjects = true.obs;
|
final isLoadingProjects = true.obs;
|
||||||
RxBool isLoadingEmployees = true.obs;
|
final isLoadingEmployees = true.obs;
|
||||||
RxBool isLoadingAttendanceLogs = true.obs;
|
final isLoadingAttendanceLogs = true.obs;
|
||||||
RxBool isLoadingRegularizationLogs = true.obs;
|
final isLoadingRegularizationLogs = true.obs;
|
||||||
RxBool isLoadingLogView = true.obs;
|
final isLoadingLogView = true.obs;
|
||||||
|
final uploadingStates = <String, RxBool>{}.obs;
|
||||||
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
@ -56,76 +58,46 @@ class AttendanceController extends GetxController {
|
|||||||
logSafe("Default date range set: $startDateAttendance to $endDateAttendance");
|
logSafe("Default date range set: $startDateAttendance to $endDateAttendance");
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _handleLocationPermission() async {
|
// ------------------ Project & Employee ------------------
|
||||||
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 {
|
Future<void> fetchProjects() async {
|
||||||
isLoadingProjects.value = true;
|
isLoadingProjects.value = true;
|
||||||
isLoading.value = true;
|
|
||||||
|
|
||||||
final response = await ApiService.getProjects();
|
final response = await ApiService.getProjects();
|
||||||
if (response != null && response.isNotEmpty) {
|
if (response != null && response.isNotEmpty) {
|
||||||
projects = response.map((json) => ProjectModel.fromJson(json)).toList();
|
projects = response.map((e) => ProjectModel.fromJson(e)).toList();
|
||||||
logSafe("Projects fetched: ${projects.length}");
|
logSafe("Projects fetched: ${projects.length}");
|
||||||
} else {
|
} else {
|
||||||
logSafe("Failed to fetch projects or no projects available.", level: LogLevel.error);
|
|
||||||
projects = [];
|
projects = [];
|
||||||
|
logSafe("Failed to fetch projects or no projects available.", level: LogLevel.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoadingProjects.value = false;
|
isLoadingProjects.value = false;
|
||||||
isLoading.value = false;
|
|
||||||
update(['attendance_dashboard_controller']);
|
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 {
|
Future<void> fetchEmployeesByProject(String? projectId) async {
|
||||||
if (projectId == null) return;
|
if (projectId == null) return;
|
||||||
|
|
||||||
isLoadingEmployees.value = true;
|
isLoadingEmployees.value = true;
|
||||||
|
|
||||||
final response = await ApiService.getEmployeesByProject(projectId);
|
final response = await ApiService.getEmployeesByProject(projectId);
|
||||||
if (response != null) {
|
if (response != null) {
|
||||||
employees = response.map((json) => EmployeeModel.fromJson(json)).toList();
|
employees = response.map((e) => EmployeeModel.fromJson(e)).toList();
|
||||||
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");
|
logSafe("Employees fetched: ${employees.length} for project $projectId");
|
||||||
update();
|
|
||||||
} else {
|
} else {
|
||||||
logSafe("Failed to fetch employees for project $projectId", level: LogLevel.error);
|
logSafe("Failed to fetch employees for project $projectId", level: LogLevel.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoadingEmployees.value = false;
|
isLoadingEmployees.value = false;
|
||||||
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------ Attendance Capture ------------------
|
||||||
|
|
||||||
Future<bool> captureAndUploadAttendance(
|
Future<bool> captureAndUploadAttendance(
|
||||||
String id,
|
String id,
|
||||||
String employeeId,
|
String employeeId,
|
||||||
@ -137,6 +109,7 @@ class AttendanceController extends GetxController {
|
|||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
uploadingStates[employeeId]?.value = true;
|
uploadingStates[employeeId]?.value = true;
|
||||||
|
|
||||||
XFile? image;
|
XFile? image;
|
||||||
if (imageCapture) {
|
if (imageCapture) {
|
||||||
image = await ImagePicker().pickImage(source: ImageSource.camera, imageQuality: 80);
|
image = await ImagePicker().pickImage(source: ImageSource.camera, imageQuality: 80);
|
||||||
@ -144,24 +117,39 @@ class AttendanceController extends GetxController {
|
|||||||
logSafe("Image capture cancelled.", level: LogLevel.warning);
|
logSafe("Image capture cancelled.", level: LogLevel.warning);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final compressedBytes = await compressImageToUnder100KB(File(image.path));
|
final compressedBytes = await compressImageToUnder100KB(File(image.path));
|
||||||
if (compressedBytes == null) {
|
if (compressedBytes == null) {
|
||||||
logSafe("Image compression failed.", level: LogLevel.error);
|
logSafe("Image compression failed.", level: LogLevel.error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final compressedFile = await saveCompressedImageToFile(compressedBytes);
|
final compressedFile = await saveCompressedImageToFile(compressedBytes);
|
||||||
image = XFile(compressedFile.path);
|
image = XFile(compressedFile.path);
|
||||||
}
|
}
|
||||||
final hasLocationPermission = await _handleLocationPermission();
|
|
||||||
if (!hasLocationPermission) return false;
|
if (!await _handleLocationPermission()) return false;
|
||||||
final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
|
final position = await Geolocator.getCurrentPosition(
|
||||||
final imageName = imageCapture ? ApiService.generateImageName(employeeId, employees.length + 1) : "";
|
desiredAccuracy: LocationAccuracy.high);
|
||||||
|
|
||||||
|
final imageName = imageCapture
|
||||||
|
? ApiService.generateImageName(employeeId, employees.length + 1)
|
||||||
|
: "";
|
||||||
|
|
||||||
final result = await ApiService.uploadAttendanceImage(
|
final result = await ApiService.uploadAttendanceImage(
|
||||||
id, employeeId, image, position.latitude, position.longitude,
|
id,
|
||||||
imageName: imageName, projectId: projectId, comment: comment,
|
employeeId,
|
||||||
action: action, imageCapture: imageCapture, markTime: markTime,
|
image,
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
imageName: imageName,
|
||||||
|
projectId: projectId,
|
||||||
|
comment: comment,
|
||||||
|
action: action,
|
||||||
|
imageCapture: imageCapture,
|
||||||
|
markTime: markTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
logSafe("Attendance uploaded for $employeeId, action: $action");
|
logSafe("Attendance uploaded for $employeeId, action: $action");
|
||||||
return result;
|
return result;
|
||||||
} catch (e, stacktrace) {
|
} catch (e, stacktrace) {
|
||||||
@ -172,8 +160,133 @@ class AttendanceController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> selectDateRangeForAttendance(BuildContext context, AttendanceController controller) async {
|
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);
|
||||||
|
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);
|
||||||
|
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 Future.wait([
|
||||||
|
fetchEmployeesByProject(projectId),
|
||||||
|
fetchAttendanceLogs(projectId,
|
||||||
|
dateFrom: startDateAttendance, dateTo: endDateAttendance),
|
||||||
|
fetchRegularizationLogs(projectId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
logSafe("Project data fetched for project ID: $projectId");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------ UI Interaction ------------------
|
||||||
|
|
||||||
|
Future<void> selectDateRangeForAttendance(
|
||||||
|
BuildContext context, AttendanceController controller) async {
|
||||||
final today = DateTime.now();
|
final today = DateTime.now();
|
||||||
|
|
||||||
final picked = await showDateRangePicker(
|
final picked = await showDateRangePicker(
|
||||||
context: context,
|
context: context,
|
||||||
firstDate: DateTime(2022),
|
firstDate: DateTime(2022),
|
||||||
@ -190,14 +303,13 @@ class AttendanceController extends GetxController {
|
|||||||
child: Theme(
|
child: Theme(
|
||||||
data: Theme.of(context).copyWith(
|
data: Theme.of(context).copyWith(
|
||||||
colorScheme: ColorScheme.light(
|
colorScheme: ColorScheme.light(
|
||||||
primary: const Color.fromARGB(255, 95, 132, 255),
|
primary: const Color(0xFF5F84FF),
|
||||||
onPrimary: Colors.white,
|
onPrimary: Colors.white,
|
||||||
onSurface: Colors.teal.shade800,
|
onSurface: Colors.teal.shade800,
|
||||||
),
|
),
|
||||||
textButtonTheme: TextButtonThemeData(
|
textButtonTheme: TextButtonThemeData(
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.teal),
|
style: TextButton.styleFrom(foregroundColor: Colors.teal),
|
||||||
),
|
),
|
||||||
dialogTheme: DialogThemeData(backgroundColor: Colors.white),
|
|
||||||
),
|
),
|
||||||
child: child!,
|
child: child!,
|
||||||
),
|
),
|
||||||
@ -210,6 +322,7 @@ class AttendanceController extends GetxController {
|
|||||||
startDateAttendance = picked.start;
|
startDateAttendance = picked.start;
|
||||||
endDateAttendance = picked.end;
|
endDateAttendance = picked.end;
|
||||||
logSafe("Date range selected: $startDateAttendance to $endDateAttendance");
|
logSafe("Date range selected: $startDateAttendance to $endDateAttendance");
|
||||||
|
|
||||||
await controller.fetchAttendanceLogs(
|
await controller.fetchAttendanceLogs(
|
||||||
Get.find<ProjectController>().selectedProject?.id,
|
Get.find<ProjectController>().selectedProject?.id,
|
||||||
dateFrom: picked.start,
|
dateFrom: picked.start,
|
||||||
@ -217,78 +330,4 @@ class AttendanceController extends GetxController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,24 +17,25 @@ 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);
|
} else if (isAllEmployeeSelected.value) {
|
||||||
} else if (isAllEmployeeSelected.value) {
|
fetchAllEmployees();
|
||||||
fetchAllEmployees();
|
} 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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -66,19 +68,19 @@ class EmployeesScreenController extends GetxController {
|
|||||||
|
|
||||||
Future<void> fetchAllEmployees() async {
|
Future<void> fetchAllEmployees() async {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
update(['employee_screen_controller']);
|
||||||
|
|
||||||
await _handleApiCall(
|
await _handleApiCall(
|
||||||
ApiService.getAllEmployees,
|
ApiService.getAllEmployees,
|
||||||
onSuccess: (data) {
|
onSuccess: (data) {
|
||||||
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
|
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
|
||||||
logSafe(
|
logSafe("All Employees fetched: ${employees.length} employees loaded.",
|
||||||
"All Employees fetched: ${employees.length} employees loaded.",
|
level: LogLevel.info);
|
||||||
level: LogLevel.info,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -88,7 +90,8 @@ class EmployeesScreenController extends GetxController {
|
|||||||
|
|
||||||
Future<void> fetchEmployeesByProject(String? projectId) async {
|
Future<void> fetchEmployeesByProject(String? projectId) async {
|
||||||
if (projectId == null || projectId.isEmpty) {
|
if (projectId == null || projectId.isEmpty) {
|
||||||
logSafe("Project ID is required but was null or empty.", level: LogLevel.error);
|
logSafe("Project ID is required but was null or empty.",
|
||||||
|
level: LogLevel.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,15 +109,21 @@ class EmployeesScreenController extends GetxController {
|
|||||||
logSafe(
|
logSafe(
|
||||||
"Employees fetched: ${employees.length} for project $projectId",
|
"Employees fetched: ${employees.length} for project $projectId",
|
||||||
level: LogLevel.info,
|
level: LogLevel.info,
|
||||||
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onEmpty: () {
|
onEmpty: () {
|
||||||
employees.clear();
|
employees.clear();
|
||||||
logSafe("No employees found for project $projectId.", level: LogLevel.warning, );
|
logSafe(
|
||||||
|
"No employees found for project $projectId.",
|
||||||
|
level: LogLevel.warning,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onError: (e) {
|
onError: (e) {
|
||||||
logSafe("Error fetching employees for project $projectId", level: LogLevel.error, error: e, );
|
logSafe(
|
||||||
|
"Error fetching employees for project $projectId",
|
||||||
|
level: LogLevel.error,
|
||||||
|
error: e,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -131,15 +140,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,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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() {
|
||||||
@ -94,6 +95,9 @@ class AddContactController extends GetxController {
|
|||||||
required String address,
|
required String address,
|
||||||
required String description,
|
required String description,
|
||||||
}) 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 bucketId = bucketsMap[selectedBucket.value];
|
||||||
final projectIds = selectedProjects
|
final projectIds = selectedProjects
|
||||||
@ -101,13 +105,13 @@ class AddContactController extends GetxController {
|
|||||||
.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,6 +121,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,10 +131,10 @@ class AddContactController extends GetxController {
|
|||||||
message: "Please select a bucket.",
|
message: "Please select a 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];
|
||||||
@ -182,6 +187,8 @@ class AddContactController extends GetxController {
|
|||||||
message: "Something went wrong",
|
message: "Something went wrong",
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
434
lib/controller/expense/add_expense_controller.dart
Normal file
434
lib/controller/expense/add_expense_controller.dart
Normal file
@ -0,0 +1,434 @@
|
|||||||
|
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: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/employee_model.dart';
|
||||||
|
import 'package:marco/model/expense/expense_type_model.dart';
|
||||||
|
import 'package:marco/model/expense/payment_types_model.dart';
|
||||||
|
import 'package:mime/mime.dart';
|
||||||
|
|
||||||
|
class AddExpenseController extends GetxController {
|
||||||
|
// --- Text Controllers ---
|
||||||
|
final amountController = TextEditingController();
|
||||||
|
final descriptionController = TextEditingController();
|
||||||
|
final supplierController = TextEditingController();
|
||||||
|
final transactionIdController = TextEditingController();
|
||||||
|
final gstController = TextEditingController();
|
||||||
|
final locationController = TextEditingController();
|
||||||
|
final transactionDateController = TextEditingController();
|
||||||
|
final noOfPersonsController = TextEditingController();
|
||||||
|
|
||||||
|
final employeeSearchController = TextEditingController();
|
||||||
|
|
||||||
|
// --- 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>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
fetchMasterData();
|
||||||
|
fetchGlobalProjects();
|
||||||
|
employeeSearchController.addListener(() {
|
||||||
|
searchEmployees(employeeSearchController.text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
for (var c in [
|
||||||
|
amountController,
|
||||||
|
descriptionController,
|
||||||
|
supplierController,
|
||||||
|
transactionIdController,
|
||||||
|
gstController,
|
||||||
|
locationController,
|
||||||
|
transactionDateController,
|
||||||
|
noOfPersonsController,
|
||||||
|
employeeSearchController,
|
||||||
|
]) {
|
||||||
|
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());
|
||||||
|
employeeSearchResults.assignAll(
|
||||||
|
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("Error searching employees: $e", level: LogLevel.error);
|
||||||
|
employeeSearchResults.clear();
|
||||||
|
} finally {
|
||||||
|
isSearchingEmployees.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Form Population: Edit Mode ---
|
||||||
|
Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
|
||||||
|
isEditMode.value = true;
|
||||||
|
editingExpenseId = '${data['id']}';
|
||||||
|
|
||||||
|
selectedProject.value = data['projectName'] ?? '';
|
||||||
|
amountController.text = data['amount']?.toString() ?? '';
|
||||||
|
supplierController.text = data['supplerName'] ?? '';
|
||||||
|
descriptionController.text = data['description'] ?? '';
|
||||||
|
transactionIdController.text = data['transactionId'] ?? '';
|
||||||
|
locationController.text = data['location'] ?? '';
|
||||||
|
noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString();
|
||||||
|
|
||||||
|
// Transaction Date
|
||||||
|
if (data['transactionDate'] != null) {
|
||||||
|
try {
|
||||||
|
final parsed = DateTime.parse(data['transactionDate']);
|
||||||
|
selectedTransactionDate.value = parsed;
|
||||||
|
transactionDateController.text =
|
||||||
|
DateFormat('dd-MM-yyyy').format(parsed);
|
||||||
|
} catch (_) {
|
||||||
|
selectedTransactionDate.value = null;
|
||||||
|
transactionDateController.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropdown
|
||||||
|
selectedExpenseType.value =
|
||||||
|
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
|
||||||
|
selectedPaymentMode.value =
|
||||||
|
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
|
||||||
|
|
||||||
|
// Paid By
|
||||||
|
final paidById = '${data['paidById']}';
|
||||||
|
selectedPaidBy.value =
|
||||||
|
allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
|
||||||
|
if (selectedPaidBy.value == null && data['paidByFirstName'] != null) {
|
||||||
|
await searchEmployees(
|
||||||
|
'${data['paidByFirstName']} ${data['paidByLastName']}');
|
||||||
|
selectedPaidBy.value = employeeSearchResults
|
||||||
|
.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
existingAttachments.clear();
|
||||||
|
if (data['attachments'] is List) {
|
||||||
|
existingAttachments.addAll(
|
||||||
|
List<Map<String, dynamic>>.from(data['attachments'])
|
||||||
|
.map((e) => {...e, 'isActive': true}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logPrefilledData();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _logPrefilledData() {
|
||||||
|
logSafe('--- Prefilled Expense Data ---', level: LogLevel.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}',
|
||||||
|
].forEach((str) => logSafe(str, level: LogLevel.info));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pickers ---
|
||||||
|
Future<void> pickTransactionDate(BuildContext context) async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: selectedTransactionDate.value ?? DateTime.now(),
|
||||||
|
firstDate: DateTime(DateTime.now().year - 5),
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
selectedTransactionDate.value = picked;
|
||||||
|
transactionDateController.text = DateFormat('dd-MM-yyyy').format(picked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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((path) => File(path)));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_errorSnackbar("Attachment error: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeAttachment(File file) => attachments.remove(file);
|
||||||
|
|
||||||
|
// --- Location ---
|
||||||
|
Future<void> fetchCurrentLocation() async {
|
||||||
|
isFetchingLocation.value = true;
|
||||||
|
try {
|
||||||
|
final permission = await _ensureLocationPermission();
|
||||||
|
if (!permission) 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 =>
|
||||||
|
await 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)).toList();
|
||||||
|
|
||||||
|
final modes = await ApiService.getMasterPaymentModes();
|
||||||
|
if (modes is List)
|
||||||
|
paymentModes.value =
|
||||||
|
modes.map((e) => PaymentModeModel.fromJson(e)).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(),
|
||||||
|
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 = isEditMode.value && editingExpenseId != null
|
||||||
|
? await ApiService.editExpenseApi(
|
||||||
|
expenseId: editingExpenseId!, payload: payload)
|
||||||
|
: await ApiService.createExpenseApi(
|
||||||
|
projectId: payload['projectId'],
|
||||||
|
expensesTypeId: payload['expensesTypeId'],
|
||||||
|
paymentModeId: payload['paymentModeId'],
|
||||||
|
paidById: payload['paidById'],
|
||||||
|
transactionDate: DateTime.parse(payload['transactionDate']),
|
||||||
|
transactionId: payload['transactionId'],
|
||||||
|
description: payload['description'],
|
||||||
|
location: payload['location'],
|
||||||
|
supplerName: payload['supplerName'],
|
||||||
|
amount: payload['amount'],
|
||||||
|
noOfPersons: payload['noOfPersons'],
|
||||||
|
billAttachments: payload['billAttachments'],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
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<Map<String, dynamic>> _buildExpensePayload() async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final existingAttachmentPayloads = existingAttachments
|
||||||
|
.map((e) => {
|
||||||
|
"documentId": e['documentId'],
|
||||||
|
"fileName": e['fileName'],
|
||||||
|
"contentType": e['contentType'],
|
||||||
|
"fileSize": 0,
|
||||||
|
"description": "",
|
||||||
|
"url": e['url'],
|
||||||
|
"isActive": e['isActive'] ?? true,
|
||||||
|
"base64Data": e['isActive'] == false ? null : e['base64Data'],
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final newAttachmentPayloads =
|
||||||
|
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?.toUtc() ?? 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": [
|
||||||
|
...existingAttachmentPayloads,
|
||||||
|
...newAttachmentPayloads
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
// Date Required
|
||||||
|
if (selectedTransactionDate.value == null) missing.add("Transaction Date");
|
||||||
|
if (selectedTransactionDate.value != null &&
|
||||||
|
selectedTransactionDate.value!.isAfter(DateTime.now())) {
|
||||||
|
missing.add("Valid Transaction Date");
|
||||||
|
}
|
||||||
|
|
||||||
|
final amount = double.tryParse(amountController.text.trim());
|
||||||
|
if (amount == null) missing.add("Valid Amount");
|
||||||
|
|
||||||
|
// Attachment: at least one required at all times
|
||||||
|
bool 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/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
349
lib/controller/expense/expense_screen_controller.dart
Normal file
349
lib/controller/expense/expense_screen_controller.dart
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
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/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);
|
||||||
|
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 {
|
||||||
|
errorMessage.value = 'Failed to fetch expenses from 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -117,6 +117,23 @@ class PermissionController extends GetxController {
|
|||||||
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();
|
||||||
|
|||||||
@ -17,6 +17,7 @@ class DailyTaskPlaningController extends GetxController {
|
|||||||
|
|
||||||
MyFormValidator basicValidator = MyFormValidator();
|
MyFormValidator basicValidator = MyFormValidator();
|
||||||
List<Map<String, dynamic>> roles = [];
|
List<Map<String, dynamic>> roles = [];
|
||||||
|
RxBool isAssigningTask = false.obs;
|
||||||
|
|
||||||
RxnString selectedRoleId = RxnString();
|
RxnString selectedRoleId = RxnString();
|
||||||
RxBool isLoading = false.obs;
|
RxBool isLoading = false.obs;
|
||||||
@ -46,16 +47,21 @@ class DailyTaskPlaningController extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void updateSelectedEmployees() {
|
void updateSelectedEmployees() {
|
||||||
final selected = employees
|
final selected =
|
||||||
.where((e) => uploadingStates[e.id]?.value == true)
|
employees.where((e) => uploadingStates[e.id]?.value == true).toList();
|
||||||
.toList();
|
|
||||||
selectedEmployees.value = selected;
|
selectedEmployees.value = selected;
|
||||||
logSafe("Updated selected employees", level: LogLevel.debug, );
|
logSafe(
|
||||||
|
"Updated selected employees",
|
||||||
|
level: LogLevel.debug,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void onRoleSelected(String? roleId) {
|
void onRoleSelected(String? roleId) {
|
||||||
selectedRoleId.value = roleId;
|
selectedRoleId.value = roleId;
|
||||||
logSafe("Role selected", level: LogLevel.info, );
|
logSafe(
|
||||||
|
"Role selected",
|
||||||
|
level: LogLevel.info,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchRoles() async {
|
Future<void> fetchRoles() async {
|
||||||
@ -77,6 +83,7 @@ class DailyTaskPlaningController extends GetxController {
|
|||||||
required List<String> taskTeam,
|
required List<String> taskTeam,
|
||||||
DateTime? assignmentDate,
|
DateTime? assignmentDate,
|
||||||
}) async {
|
}) async {
|
||||||
|
isAssigningTask.value = true;
|
||||||
logSafe("Starting assign task...", level: LogLevel.info);
|
logSafe("Starting assign task...", level: LogLevel.info);
|
||||||
|
|
||||||
final response = await ApiService.assignDailyTask(
|
final response = await ApiService.assignDailyTask(
|
||||||
@ -87,6 +94,8 @@ class DailyTaskPlaningController extends GetxController {
|
|||||||
assignmentDate: assignmentDate,
|
assignmentDate: assignmentDate,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
isAssigningTask.value = false;
|
||||||
|
|
||||||
if (response == true) {
|
if (response == true) {
|
||||||
logSafe("Task assigned successfully", level: LogLevel.info);
|
logSafe("Task assigned successfully", level: LogLevel.info);
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
@ -111,15 +120,18 @@ class DailyTaskPlaningController extends GetxController {
|
|||||||
try {
|
try {
|
||||||
final response = await ApiService.getProjects();
|
final response = await ApiService.getProjects();
|
||||||
if (response?.isEmpty ?? true) {
|
if (response?.isEmpty ?? true) {
|
||||||
logSafe("No project data found or API call failed", level: LogLevel.warning);
|
logSafe("No project data found or API call failed",
|
||||||
|
level: LogLevel.warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
projects = response!.map((json) => ProjectModel.fromJson(json)).toList();
|
projects = response!.map((json) => ProjectModel.fromJson(json)).toList();
|
||||||
logSafe("Projects fetched: ${projects.length} projects loaded", level: LogLevel.info);
|
logSafe("Projects fetched: ${projects.length} projects loaded",
|
||||||
|
level: LogLevel.info);
|
||||||
update();
|
update();
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
logSafe("Error fetching projects", level: LogLevel.error, error: e, stackTrace: stack);
|
logSafe("Error fetching projects",
|
||||||
|
level: LogLevel.error, error: e, stackTrace: stack);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
@ -137,12 +149,16 @@ class DailyTaskPlaningController extends GetxController {
|
|||||||
final data = response?['data'];
|
final data = response?['data'];
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
dailyTasks = [TaskPlanningDetailsModel.fromJson(data)];
|
dailyTasks = [TaskPlanningDetailsModel.fromJson(data)];
|
||||||
logSafe("Daily task Planning Details fetched", level: LogLevel.info, );
|
logSafe(
|
||||||
|
"Daily task Planning Details fetched",
|
||||||
|
level: LogLevel.info,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
logSafe("Data field is null", level: LogLevel.warning);
|
logSafe("Data field is null", level: LogLevel.warning);
|
||||||
}
|
}
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
logSafe("Error fetching daily task data", level: LogLevel.error, error: e, stackTrace: stack);
|
logSafe("Error fetching daily task data",
|
||||||
|
level: LogLevel.error, error: e, stackTrace: stack);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
update();
|
update();
|
||||||
@ -151,7 +167,8 @@ class DailyTaskPlaningController extends GetxController {
|
|||||||
|
|
||||||
Future<void> fetchEmployeesByProject(String? projectId) async {
|
Future<void> fetchEmployeesByProject(String? projectId) async {
|
||||||
if (projectId == null || projectId.isEmpty) {
|
if (projectId == null || projectId.isEmpty) {
|
||||||
logSafe("Project ID is required but was null or empty", level: LogLevel.error);
|
logSafe("Project ID is required but was null or empty",
|
||||||
|
level: LogLevel.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,19 +176,29 @@ class DailyTaskPlaningController extends GetxController {
|
|||||||
try {
|
try {
|
||||||
final response = await ApiService.getAllEmployeesByProject(projectId);
|
final response = await ApiService.getAllEmployeesByProject(projectId);
|
||||||
if (response != null && response.isNotEmpty) {
|
if (response != null && response.isNotEmpty) {
|
||||||
employees = response.map((json) => EmployeeModel.fromJson(json)).toList();
|
employees =
|
||||||
|
response.map((json) => EmployeeModel.fromJson(json)).toList();
|
||||||
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",
|
logSafe(
|
||||||
level: LogLevel.info, );
|
"Employees fetched: ${employees.length} for project $projectId",
|
||||||
|
level: LogLevel.info,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
employees = [];
|
employees = [];
|
||||||
logSafe("No employees found for project $projectId", level: LogLevel.warning, );
|
logSafe(
|
||||||
|
"No employees found for project $projectId",
|
||||||
|
level: LogLevel.warning,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
logSafe("Error fetching employees for project $projectId",
|
logSafe(
|
||||||
level: LogLevel.error, error: e, stackTrace: stack, );
|
"Error fetching employees for project $projectId",
|
||||||
|
level: LogLevel.error,
|
||||||
|
error: e,
|
||||||
|
stackTrace: stack,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
update();
|
update();
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
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";
|
||||||
|
|
||||||
// Dashboard Screen API Endpoints
|
// Dashboard Module API Endpoints
|
||||||
static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview";
|
static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview";
|
||||||
|
|
||||||
// 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 getEmployeesByProject = "/attendance/project/team";
|
||||||
@ -15,13 +15,17 @@ class ApiEndpoints {
|
|||||||
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 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/manage-mobile";
|
||||||
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";
|
||||||
@ -32,7 +36,7 @@ class ApiEndpoints {
|
|||||||
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";
|
||||||
|
|
||||||
////// 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";
|
||||||
@ -46,4 +50,16 @@ class ApiEndpoints {
|
|||||||
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";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -239,6 +239,335 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Expense APIs === //
|
||||||
|
|
||||||
|
/// Edit Expense API
|
||||||
|
static Future<bool> editExpenseApi({
|
||||||
|
required String expenseId,
|
||||||
|
required Map<String, dynamic> payload,
|
||||||
|
}) async {
|
||||||
|
final endpoint = "${ApiEndpoints.editExpense}/$expenseId";
|
||||||
|
logSafe("Editing expense $expenseId with payload: $payload");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _putRequest(
|
||||||
|
endpoint,
|
||||||
|
payload,
|
||||||
|
customTimeout: extendedTimeout,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Edit expense failed: null response", level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Edit expense response status: ${response.statusCode}");
|
||||||
|
logSafe("Edit expense response body: ${response.body}");
|
||||||
|
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
if (json['success'] == true) {
|
||||||
|
logSafe("Expense updated successfully: ${json['data']}");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logSafe(
|
||||||
|
"Failed to update expense: ${json['message'] ?? 'Unknown error'}",
|
||||||
|
level: LogLevel.warning,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during editExpenseApi: $e", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> deleteExpense(String expenseId) async {
|
||||||
|
final endpoint = "${ApiEndpoints.deleteExpense}/$expenseId";
|
||||||
|
|
||||||
|
try {
|
||||||
|
final token = await _getToken();
|
||||||
|
if (token == null) {
|
||||||
|
logSafe("Token is null. Cannot proceed with DELETE request.",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint");
|
||||||
|
|
||||||
|
logSafe("Sending DELETE request to $uri", level: LogLevel.debug);
|
||||||
|
|
||||||
|
final response =
|
||||||
|
await http.delete(uri, headers: _headers(token)).timeout(timeout);
|
||||||
|
|
||||||
|
logSafe("DELETE expense response status: ${response.statusCode}");
|
||||||
|
logSafe("DELETE expense response body: ${response.body}");
|
||||||
|
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
if (response.statusCode == 200 && json['success'] == true) {
|
||||||
|
logSafe("Expense deleted successfully.");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logSafe(
|
||||||
|
"Failed to delete expense: ${json['message'] ?? 'Unknown error'}",
|
||||||
|
level: LogLevel.warning);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during deleteExpenseApi: $e", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Expense Details API
|
||||||
|
static Future<Map<String, dynamic>?> getExpenseDetailsApi({
|
||||||
|
required String expenseId,
|
||||||
|
}) async {
|
||||||
|
final endpoint = "${ApiEndpoints.getExpenseDetails}/$expenseId";
|
||||||
|
logSafe("Fetching expense details for ID: $expenseId");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _getRequest(endpoint);
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Expense details request failed: null response",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = response.body.trim();
|
||||||
|
if (body.isEmpty) {
|
||||||
|
logSafe("Expense details response body is empty",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonResponse = jsonDecode(body);
|
||||||
|
if (jsonResponse is Map<String, dynamic>) {
|
||||||
|
if (jsonResponse['success'] == true) {
|
||||||
|
logSafe("Expense details fetched successfully");
|
||||||
|
return jsonResponse['data']; // Return the expense details object
|
||||||
|
} else {
|
||||||
|
logSafe(
|
||||||
|
"Failed to fetch expense details: ${jsonResponse['message'] ?? 'Unknown error'}",
|
||||||
|
level: LogLevel.warning,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logSafe("Unexpected response structure: $jsonResponse",
|
||||||
|
level: LogLevel.error);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during getExpenseDetailsApi: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update Expense Status API
|
||||||
|
static Future<bool> updateExpenseStatusApi({
|
||||||
|
required String expenseId,
|
||||||
|
required String statusId,
|
||||||
|
String? comment,
|
||||||
|
String? reimburseTransactionId,
|
||||||
|
String? reimburseDate,
|
||||||
|
String? reimbursedById,
|
||||||
|
}) async {
|
||||||
|
final Map<String, dynamic> payload = {
|
||||||
|
"expenseId": expenseId,
|
||||||
|
"statusId": statusId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (comment != null) {
|
||||||
|
payload["comment"] = comment;
|
||||||
|
}
|
||||||
|
if (reimburseTransactionId != null) {
|
||||||
|
payload["reimburseTransactionId"] = reimburseTransactionId;
|
||||||
|
}
|
||||||
|
if (reimburseDate != null) {
|
||||||
|
payload["reimburseDate"] = reimburseDate;
|
||||||
|
}
|
||||||
|
if (reimbursedById != null) {
|
||||||
|
payload["reimburseById"] = reimbursedById;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = ApiEndpoints.updateExpenseStatus;
|
||||||
|
logSafe("Updating expense status with payload: $payload");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _postRequest(
|
||||||
|
endpoint,
|
||||||
|
payload,
|
||||||
|
customTimeout: extendedTimeout,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Update expense status failed: null response",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Update expense status response status: ${response.statusCode}");
|
||||||
|
logSafe("Update expense status response body: ${response.body}");
|
||||||
|
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
if (json['success'] == true) {
|
||||||
|
logSafe("Expense status updated successfully: ${json['data']}");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logSafe(
|
||||||
|
"Failed to update expense status: ${json['message'] ?? 'Unknown error'}",
|
||||||
|
level: LogLevel.warning,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during updateExpenseStatus API: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>?> getExpenseListApi({
|
||||||
|
String? filter,
|
||||||
|
int pageSize = 20,
|
||||||
|
int pageNumber = 1,
|
||||||
|
}) async {
|
||||||
|
// Build the endpoint with query parameters
|
||||||
|
String endpoint = ApiEndpoints.getExpenseList;
|
||||||
|
final queryParams = <String, String>{
|
||||||
|
'pageSize': pageSize.toString(),
|
||||||
|
'pageNumber': pageNumber.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filter != null && filter.isNotEmpty) {
|
||||||
|
queryParams['filter'] = filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the full URI
|
||||||
|
final uri = Uri.parse(endpoint).replace(queryParameters: queryParams);
|
||||||
|
logSafe("Fetching expense list with URI: $uri");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _getRequest(uri.toString());
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Expense list request failed: null response",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directly parse and return the entire JSON response
|
||||||
|
final body = response.body.trim();
|
||||||
|
if (body.isEmpty) {
|
||||||
|
logSafe("Expense list response body is empty", level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonResponse = jsonDecode(body);
|
||||||
|
if (jsonResponse is Map<String, dynamic>) {
|
||||||
|
logSafe("Expense list response parsed successfully");
|
||||||
|
return jsonResponse; // Return the entire API response
|
||||||
|
} else {
|
||||||
|
logSafe("Unexpected response structure: $jsonResponse",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during getExpenseListApi: $e", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch Master Payment Modes
|
||||||
|
static Future<List<dynamic>?> getMasterPaymentModes() async {
|
||||||
|
const endpoint = ApiEndpoints.getMasterPaymentModes;
|
||||||
|
return _getRequest(endpoint).then((res) => res != null
|
||||||
|
? _parseResponse(res, label: 'Master Payment Modes')
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch Master Expense Status
|
||||||
|
static Future<List<dynamic>?> getMasterExpenseStatus() async {
|
||||||
|
const endpoint = ApiEndpoints.getMasterExpenseStatus;
|
||||||
|
return _getRequest(endpoint).then((res) => res != null
|
||||||
|
? _parseResponse(res, label: 'Master Expense Status')
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch Master Expense Types
|
||||||
|
static Future<List<dynamic>?> getMasterExpenseTypes() async {
|
||||||
|
const endpoint = ApiEndpoints.getMasterExpenseTypes;
|
||||||
|
return _getRequest(endpoint).then((res) => res != null
|
||||||
|
? _parseResponse(res, label: 'Master Expense Types')
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create Expense API
|
||||||
|
static Future<bool> createExpenseApi({
|
||||||
|
required String projectId,
|
||||||
|
required String expensesTypeId,
|
||||||
|
required String paymentModeId,
|
||||||
|
required String paidById,
|
||||||
|
required DateTime transactionDate,
|
||||||
|
required String transactionId,
|
||||||
|
required String description,
|
||||||
|
required String location,
|
||||||
|
required String supplerName,
|
||||||
|
required double amount,
|
||||||
|
required int noOfPersons,
|
||||||
|
required List<Map<String, dynamic>> billAttachments,
|
||||||
|
}) async {
|
||||||
|
final payload = {
|
||||||
|
"projectId": projectId,
|
||||||
|
"expensesTypeId": expensesTypeId,
|
||||||
|
"paymentModeId": paymentModeId,
|
||||||
|
"paidById": paidById,
|
||||||
|
"transactionDate": transactionDate.toIso8601String(),
|
||||||
|
"transactionId": transactionId,
|
||||||
|
"description": description,
|
||||||
|
"location": location,
|
||||||
|
"supplerName": supplerName,
|
||||||
|
"amount": amount,
|
||||||
|
"noOfPersons": noOfPersons,
|
||||||
|
"billAttachments": billAttachments,
|
||||||
|
};
|
||||||
|
|
||||||
|
const endpoint = ApiEndpoints.createExpense;
|
||||||
|
logSafe("Creating expense with payload: $payload");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response =
|
||||||
|
await _postRequest(endpoint, payload, customTimeout: extendedTimeout);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Create expense failed: null response", level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Create expense response status: ${response.statusCode}");
|
||||||
|
logSafe("Create expense response body: ${response.body}");
|
||||||
|
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
if (json['success'] == true) {
|
||||||
|
logSafe("Expense created successfully: ${json['data']}");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logSafe(
|
||||||
|
"Failed to create expense: ${json['message'] ?? 'Unknown error'}",
|
||||||
|
level: LogLevel.warning);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during createExpense API: $e", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// === Dashboard Endpoints ===
|
// === Dashboard Endpoints ===
|
||||||
|
|
||||||
static Future<List<dynamic>?> getDashboardAttendanceOverview(
|
static Future<List<dynamic>?> getDashboardAttendanceOverview(
|
||||||
@ -477,6 +806,83 @@ class ApiService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get list of assigned projects for a specific employee
|
||||||
|
/// Get list of assigned projects for a specific employee
|
||||||
|
static Future<List<dynamic>?> getAssignedProjects(String employeeId) async {
|
||||||
|
if (employeeId.isEmpty) {
|
||||||
|
throw ArgumentError("employeeId must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
final endpoint = "${ApiEndpoints.getAssignedProjects}/$employeeId";
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _getRequest(endpoint);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Failed to fetch assigned projects: null response",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final parsed = _parseResponse(response, label: "Assigned Projects");
|
||||||
|
if (parsed is List) {
|
||||||
|
return parsed;
|
||||||
|
} else {
|
||||||
|
logSafe("Unexpected response format for assigned projects.",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during getAssignedProjects API: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign projects to a specific employee
|
||||||
|
static Future<bool> assignProjects({
|
||||||
|
required String employeeId,
|
||||||
|
required List<Map<String, dynamic>> projects,
|
||||||
|
}) async {
|
||||||
|
if (employeeId.isEmpty) {
|
||||||
|
throw ArgumentError("employeeId must not be empty");
|
||||||
|
}
|
||||||
|
if (projects.isEmpty) {
|
||||||
|
throw ArgumentError("projects list must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
final endpoint = "${ApiEndpoints.assignProjects}/$employeeId";
|
||||||
|
|
||||||
|
logSafe("Assigning projects to employee $employeeId: $projects");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _postRequest(endpoint, projects);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Assign projects failed: null response", level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Assign projects response status: ${response.statusCode}");
|
||||||
|
logSafe("Assign projects response body: ${response.body}");
|
||||||
|
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
if (json['success'] == true) {
|
||||||
|
logSafe("Projects assigned successfully");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logSafe("Failed to assign projects: ${json['message']}",
|
||||||
|
level: LogLevel.warning);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during assignProjects API: $e", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
static Future<bool> updateContactComment(
|
static Future<bool> updateContactComment(
|
||||||
String commentId, String note, String contactId) async {
|
String commentId, String note, String contactId) async {
|
||||||
final payload = {
|
final payload = {
|
||||||
@ -745,14 +1151,42 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === Employee APIs ===
|
// === Employee APIs ===
|
||||||
|
/// Search employees by first name and last name only (not middle name)
|
||||||
|
/// Returns a list of up to 10 employee records matching the search string.
|
||||||
|
static Future<List<dynamic>?> searchEmployeesBasic({
|
||||||
|
String? searchString,
|
||||||
|
}) async {
|
||||||
|
// Remove ArgumentError check because searchString is optional now
|
||||||
|
|
||||||
|
final queryParams = <String, String>{};
|
||||||
|
|
||||||
|
// Add searchString to query parameters only if it's not null or empty
|
||||||
|
if (searchString != null && searchString.isNotEmpty) {
|
||||||
|
queryParams['searchString'] = searchString;
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await _getRequest(
|
||||||
|
ApiEndpoints.getEmployeesWithoutPermission,
|
||||||
|
queryParams: queryParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response != null) {
|
||||||
|
return _parseResponse(response, label: 'Search Employees Basic');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
static Future<List<dynamic>?> getAllEmployeesByProject(
|
static Future<List<dynamic>?> getAllEmployeesByProject(
|
||||||
String projectId) async {
|
String projectId) async {
|
||||||
if (projectId.isEmpty) throw ArgumentError('projectId must not be empty');
|
if (projectId.isEmpty) throw ArgumentError('projectId must not be empty');
|
||||||
final endpoint = "${ApiEndpoints.getAllEmployeesByProject}/$projectId";
|
final endpoint =
|
||||||
return _getRequest(endpoint).then((res) => res != null
|
"${ApiEndpoints.getAllEmployeesByProject}?projectId=$projectId";
|
||||||
? _parseResponse(res, label: 'Employees by Project')
|
return _getRequest(endpoint).then(
|
||||||
: null);
|
(res) => res != null
|
||||||
|
? _parseResponse(res, label: 'Employees by Project')
|
||||||
|
: null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<dynamic>?> getAllEmployees() async =>
|
static Future<List<dynamic>?> getAllEmployees() async =>
|
||||||
@ -762,8 +1196,7 @@ class ApiService {
|
|||||||
static Future<List<dynamic>?> getRoles() async =>
|
static Future<List<dynamic>?> getRoles() async =>
|
||||||
_getRequest(ApiEndpoints.getRoles).then(
|
_getRequest(ApiEndpoints.getRoles).then(
|
||||||
(res) => res != null ? _parseResponse(res, label: 'Roles') : null);
|
(res) => res != null ? _parseResponse(res, label: 'Roles') : null);
|
||||||
|
static Future<Map<String, dynamic>?> createEmployee({
|
||||||
static Future<bool> createEmployee({
|
|
||||||
required String firstName,
|
required String firstName,
|
||||||
required String lastName,
|
required String lastName,
|
||||||
required String phoneNumber,
|
required String phoneNumber,
|
||||||
@ -777,15 +1210,20 @@ class ApiService {
|
|||||||
"gender": gender,
|
"gender": gender,
|
||||||
"jobRoleId": jobRoleId,
|
"jobRoleId": jobRoleId,
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await _postRequest(
|
final response = await _postRequest(
|
||||||
ApiEndpoints.createEmployee,
|
ApiEndpoints.createEmployee,
|
||||||
body,
|
body,
|
||||||
customTimeout: extendedTimeout,
|
customTimeout: extendedTimeout,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response == null) return false;
|
if (response == null) return null;
|
||||||
|
|
||||||
final json = jsonDecode(response.body);
|
final json = jsonDecode(response.body);
|
||||||
return response.statusCode == 200 && json['success'] == true;
|
return {
|
||||||
|
"success": response.statusCode == 200 && json['success'] == true,
|
||||||
|
"data": json
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>?> getEmployeeDetails(
|
static Future<Map<String, dynamic>?> getEmployeeDetails(
|
||||||
|
|||||||
@ -8,41 +8,53 @@ import 'package:marco/helpers/theme/app_theme.dart';
|
|||||||
import 'package:url_strategy/url_strategy.dart';
|
import 'package:url_strategy/url_strategy.dart';
|
||||||
import 'package:marco/helpers/services/app_logger.dart';
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
import 'package:marco/helpers/services/auth_service.dart';
|
import 'package:marco/helpers/services/auth_service.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
Future<void> initializeApp() async {
|
Future<void> initializeApp() async {
|
||||||
try {
|
try {
|
||||||
logSafe("💡 Starting app initialization...");
|
logSafe("💡 Starting app initialization...");
|
||||||
|
|
||||||
|
// UI Setup
|
||||||
setPathUrlStrategy();
|
setPathUrlStrategy();
|
||||||
logSafe("💡 URL strategy set.");
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
const SystemUiOverlayStyle(
|
||||||
statusBarColor: Color.fromARGB(255, 255, 0, 0),
|
statusBarColor: Colors.transparent,
|
||||||
statusBarIconBrightness: Brightness.light,
|
systemNavigationBarColor: Colors.transparent,
|
||||||
));
|
statusBarIconBrightness: Brightness.light,
|
||||||
logSafe("💡 System UI overlay style set.");
|
systemNavigationBarIconBrightness: Brightness.dark,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
logSafe("💡 UI setup completed.");
|
||||||
|
|
||||||
|
// Local storage
|
||||||
await LocalStorage.init();
|
await LocalStorage.init();
|
||||||
logSafe("💡 Local storage initialized.");
|
logSafe("💡 Local storage initialized.");
|
||||||
|
|
||||||
// If a refresh token is found, try to refresh the JWT token
|
// Token handling
|
||||||
final refreshToken = await LocalStorage.getRefreshToken();
|
final refreshToken = await LocalStorage.getRefreshToken();
|
||||||
if (refreshToken != null && refreshToken.isNotEmpty) {
|
final hasRefreshToken = refreshToken?.isNotEmpty ?? false;
|
||||||
|
|
||||||
|
if (hasRefreshToken) {
|
||||||
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
|
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
|
||||||
final success = await AuthService.refreshToken();
|
final success = await AuthService.refreshToken();
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
logSafe("⚠️ Refresh token invalid or expired. Skipping controller injection.");
|
logSafe("⚠️ Refresh token invalid or expired. Skipping controller injection.");
|
||||||
// Optionally, clear tokens and force logout here if needed
|
// Optionally clear tokens or handle logout here
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logSafe("❌ No refresh token found. Skipping refresh.");
|
logSafe("❌ No refresh token found. Skipping refresh.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme setup
|
||||||
await ThemeCustomizer.init();
|
await ThemeCustomizer.init();
|
||||||
logSafe("💡 Theme customizer initialized.");
|
logSafe("💡 Theme customizer initialized.");
|
||||||
|
|
||||||
|
// Controller setup
|
||||||
final token = LocalStorage.getString('jwt_token');
|
final token = LocalStorage.getString('jwt_token');
|
||||||
if (token != null && token.isNotEmpty) {
|
final hasJwt = token?.isNotEmpty ?? false;
|
||||||
|
|
||||||
|
if (hasJwt) {
|
||||||
if (!Get.isRegistered<PermissionController>()) {
|
if (!Get.isRegistered<PermissionController>()) {
|
||||||
Get.put(PermissionController());
|
Get.put(PermissionController());
|
||||||
logSafe("💡 PermissionController injected.");
|
logSafe("💡 PermissionController injected.");
|
||||||
@ -53,13 +65,13 @@ Future<void> initializeApp() async {
|
|||||||
logSafe("💡 ProjectController injected as permanent.");
|
logSafe("💡 ProjectController injected as permanent.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load data into controllers if required
|
await Get.find<PermissionController>().loadData(token!);
|
||||||
await Get.find<PermissionController>().loadData(token);
|
|
||||||
await Get.find<ProjectController>().fetchProjects();
|
await Get.find<ProjectController>().fetchProjects();
|
||||||
} else {
|
} else {
|
||||||
logSafe("⚠️ No valid JWT token found. Skipping controller initialization.");
|
logSafe("⚠️ No valid JWT token found. Skipping controller initialization.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final style setup
|
||||||
AppStyle.init();
|
AppStyle.init();
|
||||||
logSafe("💡 AppStyle initialized.");
|
logSafe("💡 AppStyle initialized.");
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +1,14 @@
|
|||||||
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';
|
||||||
|
|
||||||
/// Global logger instance
|
/// Global logger instance
|
||||||
late final Logger appLogger;
|
late final Logger appLogger;
|
||||||
|
|
||||||
/// Log file output handler
|
|
||||||
late final FileLogOutput fileLogOutput;
|
late final FileLogOutput fileLogOutput;
|
||||||
|
|
||||||
/// Initialize logging (call once in `main()`)
|
/// Initialize logging
|
||||||
Future<void> initLogging() async {
|
Future<void> initLogging() async {
|
||||||
await requestStoragePermission();
|
|
||||||
|
|
||||||
fileLogOutput = FileLogOutput();
|
fileLogOutput = FileLogOutput();
|
||||||
|
|
||||||
appLogger = Logger(
|
appLogger = Logger(
|
||||||
@ -23,21 +19,13 @@ 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+)
|
|
||||||
Future<void> requestStoragePermission() async {
|
|
||||||
final status = await Permission.manageExternalStorage.status;
|
|
||||||
if (!status.isGranted) {
|
|
||||||
await Permission.manageExternalStorage.request();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Safe logger wrapper
|
/// Safe logger wrapper
|
||||||
void logSafe(
|
void logSafe(
|
||||||
String message, {
|
String message, {
|
||||||
@ -66,15 +54,15 @@ void logSafe(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Custom log output that writes to a local `.txt` file
|
/// Log output to file (safe path, no permission required)
|
||||||
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);
|
||||||
}
|
}
|
||||||
@ -119,7 +107,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,7 +122,7 @@ class FileLogOutput extends LogOutput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A simple, readable log printer for file output
|
/// Simple log printer for file output
|
||||||
class SimpleFileLogPrinter extends LogPrinter {
|
class SimpleFileLogPrinter extends LogPrinter {
|
||||||
@override
|
@override
|
||||||
List<String> log(LogEvent event) {
|
List<String> log(LogEvent event) {
|
||||||
@ -152,5 +139,5 @@ class SimpleFileLogPrinter extends LogPrinter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optional log level enum for better type safety
|
/// Optional enum for log levels
|
||||||
enum LogLevel { debug, info, warning, error, verbose }
|
enum LogLevel { debug, info, warning, error, verbose }
|
||||||
|
|||||||
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: 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),
|
||||||
|
|
||||||
|
// 👇 Buttons (if enabled)
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// 👇 Optional Bottom Content
|
||||||
|
if (bottomContent != null) ...[
|
||||||
|
MySpacing.height(12),
|
||||||
|
bottomContent!,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,10 +2,9 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:marco/helpers/services/app_logger.dart';
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
|
|
||||||
class DateTimeUtils {
|
class DateTimeUtils {
|
||||||
|
/// Converts a UTC datetime string to local time and formats it.
|
||||||
static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) {
|
static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) {
|
||||||
try {
|
try {
|
||||||
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,13 +16,10 @@ 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');
|
|
||||||
|
|
||||||
final formatted = _formatDateTime(localDateTime, format: format);
|
final formatted = _formatDateTime(localDateTime, format: format);
|
||||||
logSafe('Formatted Local Time: $formatted');
|
|
||||||
|
|
||||||
return formatted;
|
return formatted;
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
@ -32,6 +28,17 @@ class DateTimeUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public utility for formatting any DateTime.
|
||||||
|
static String formatDate(DateTime date, String format) {
|
||||||
|
try {
|
||||||
|
return DateFormat(format).format(date);
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
logSafe('formatDate failed: $e', error: e, stackTrace: stackTrace);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal formatter with default format.
|
||||||
static String _formatDateTime(DateTime dateTime, {String format = 'dd-MM-yyyy'}) {
|
static String _formatDateTime(DateTime dateTime, {String format = 'dd-MM-yyyy'}) {
|
||||||
return DateFormat(format).format(dateTime);
|
return DateFormat(format).format(dateTime);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,9 @@ class Permissions {
|
|||||||
static const String manageProject = "172fc9b6-755b-4f62-ab26-55c34a330614";
|
static const String manageProject = "172fc9b6-755b-4f62-ab26-55c34a330614";
|
||||||
static const String viewProjects = "6ea44136-987e-44ba-9e5d-1cf8f5837ebc";
|
static const String viewProjects = "6ea44136-987e-44ba-9e5d-1cf8f5837ebc";
|
||||||
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 manageProjectInfra = "f2aee20a-b754-4537-8166-f9507b44585b";
|
||||||
static const String viewProjectInfra = "c7b68e33-72f0-474f-bd96-77636427ecc8";
|
static const String viewProjectInfra = "c7b68e33-72f0-474f-bd96-77636427ecc8";
|
||||||
static const String regularizeAttendance ="57802c4a-00aa-4a1f-a048-fd2f70dd44b6";
|
static const String regularizeAttendance = "57802c4a-00aa-4a1f-a048-fd2f70dd44b6";
|
||||||
static const String assignToProject = "fbd213e0-0250-46f1-9f5f-4b2a1e6e76a3";
|
static const String assignToProject = "fbd213e0-0250-46f1-9f5f-4b2a1e6e76a3";
|
||||||
static const String infrastructure = "9666de86-d7c7-4d3d-acaa-fcd6d6b81f3c";
|
static const String infrastructure = "9666de86-d7c7-4d3d-acaa-fcd6d6b81f3c";
|
||||||
static const String manageTask = "08752f33-3b29-4816-b76b-ea8a968ed3c5";
|
static const String manageTask = "08752f33-3b29-4816-b76b-ea8a968ed3c5";
|
||||||
@ -13,4 +13,13 @@ class Permissions {
|
|||||||
static const String assignReportTask = "6a32379b-8b3f-49a6-8c48-4b7ac1b55dc2";
|
static const String assignReportTask = "6a32379b-8b3f-49a6-8c48-4b7ac1b55dc2";
|
||||||
static const String directoryAdmin = "4286a13b-bb40-4879-8c6d-18e9e393beda";
|
static const String directoryAdmin = "4286a13b-bb40-4879-8c6d-18e9e393beda";
|
||||||
static const String directoryManager = "62668630-13ce-4f52-a0f0-db38af2230c5";
|
static const String directoryManager = "62668630-13ce-4f52-a0f0-db38af2230c5";
|
||||||
|
|
||||||
|
// Expense Permissions
|
||||||
|
static const String expenseViewSelf = "385be49f-8fde-440e-bdbc-3dffeb8dd116";
|
||||||
|
static const String expenseViewAll = "01e06444-9ca7-4df4-b900-8c3fa051b92f";
|
||||||
|
static const String expenseUpload = "0f57885d-bcb2-4711-ac95-d841ace6d5a7";
|
||||||
|
static const String expenseReview = "1f4bda08-1873-449a-bb66-3e8222bd871b";
|
||||||
|
static const String expenseApprove = "eaafdd76-8aac-45f9-a530-315589c6deca";
|
||||||
|
static const String expenseProcess = "ea5a1529-4ee8-4828-80ea-0e23c9d4dd11";
|
||||||
|
static const String expenseManage = "bdee29a2-b73b-402d-8dd1-c4b1f81ccbc3";
|
||||||
}
|
}
|
||||||
|
|||||||
96
lib/helpers/widgets/expense_detail_helpers.dart
Normal file
96
lib/helpers/widgets/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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
437
lib/helpers/widgets/expense_main_components.dart
Normal file
437
lib/helpers/widgets/expense_main_components.dart
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
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/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/model/expense/expense_list_model.dart';
|
||||||
|
import 'package:marco/view/expense/expense_detail_screen.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 VoidCallback onRefreshTap;
|
||||||
|
final ExpenseController expenseController;
|
||||||
|
|
||||||
|
const SearchAndFilter({
|
||||||
|
required this.controller,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.onFilterTap,
|
||||||
|
required this.onRefreshTap,
|
||||||
|
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(8),
|
||||||
|
Tooltip(
|
||||||
|
message: 'Refresh Data',
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.refresh, color: Colors.green, size: 24),
|
||||||
|
onPressed: onRefreshTap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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>();
|
||||||
|
final RxBool isDeleting = false.obs;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Obx(() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
||||||
|
child: isDeleting.value
|
||||||
|
? const SizedBox(
|
||||||
|
height: 100,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.delete,
|
||||||
|
size: 48, color: Colors.redAccent),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
MyText.titleLarge("Delete Expense",
|
||||||
|
fontWeight: 600,
|
||||||
|
color:
|
||||||
|
Theme.of(context).colorScheme.onBackground),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
MyText.bodySmall(
|
||||||
|
"Are you sure you want to delete this draft expense?",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
icon:
|
||||||
|
const Icon(Icons.close, color: Colors.white),
|
||||||
|
label: MyText.bodyMedium(
|
||||||
|
"Cancel",
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
isDeleting.value = true;
|
||||||
|
await controller.deleteExpense(expense.id);
|
||||||
|
isDeleting.value = false;
|
||||||
|
Navigator.pop(context);
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Deleted',
|
||||||
|
message: 'Expense has been deleted.',
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.delete_forever,
|
||||||
|
color: Colors.white),
|
||||||
|
label: MyText.bodyMedium(
|
||||||
|
"Delete",
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.redAccent,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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, hh:mm a',
|
||||||
|
);
|
||||||
|
|
||||||
|
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.amount.toStringAsFixed(2)}',
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,36 +4,34 @@ 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(
|
children: List.generate(5, (index) {
|
||||||
children: List.generate(5, (index) {
|
return Padding(
|
||||||
return Padding(
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
child: SingleChildScrollView(
|
||||||
child: SingleChildScrollView(
|
scrollDirection: Axis.horizontal,
|
||||||
scrollDirection: Axis.horizontal,
|
child: Row(
|
||||||
child: Row(
|
children: List.generate(6, (i) {
|
||||||
children: List.generate(6, (i) {
|
return Container(
|
||||||
return Container(
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
width: 48,
|
||||||
width: 48,
|
height: 16,
|
||||||
height: 16,
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
color: Colors.grey.shade300,
|
||||||
color: Colors.grey.shade300,
|
borderRadius: BorderRadius.circular(6),
|
||||||
borderRadius: BorderRadius.circular(6),
|
),
|
||||||
),
|
);
|
||||||
);
|
}),
|
||||||
}),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}),
|
||||||
}),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Employee List - Card Style
|
// Employee List - Card Style
|
||||||
static Widget employeeListSkeletonLoader() {
|
static Widget employeeListSkeletonLoader() {
|
||||||
@ -63,25 +61,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 +132,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 +189,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,58 +249,198 @@ static Widget buildLoadingSkeleton() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
static Widget contactSkeletonCard() {
|
|
||||||
return MyCard.bordered(
|
static Widget expenseListSkeletonLoader() {
|
||||||
margin: MySpacing.only(bottom: 12),
|
return ListView.separated(
|
||||||
paddingAll: 16,
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
|
||||||
borderRadiusAll: 16,
|
itemCount: 6, // Show 6 skeleton items
|
||||||
shadow: MyShadow(
|
separatorBuilder: (_, __) =>
|
||||||
elevation: 1.5,
|
Divider(color: Colors.grey.shade300, height: 20),
|
||||||
position: MyShadowPosition.bottom,
|
itemBuilder: (context, index) {
|
||||||
),
|
return Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
children: [
|
||||||
Container(
|
// Title and Amount
|
||||||
height: 40,
|
Row(
|
||||||
width: 40,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
color: Colors.grey.shade300,
|
Container(
|
||||||
shape: BoxShape.circle,
|
height: 14,
|
||||||
),
|
width: 120,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
height: 14,
|
||||||
|
width: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
MySpacing.width(12),
|
const SizedBox(height: 6),
|
||||||
Expanded(
|
// Date and Status
|
||||||
child: Column(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
Container(
|
||||||
Container(
|
height: 12,
|
||||||
height: 12,
|
width: 100,
|
||||||
width: 100,
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
MySpacing.height(6),
|
),
|
||||||
Container(
|
const Spacer(),
|
||||||
height: 10,
|
Container(
|
||||||
width: 60,
|
height: 12,
|
||||||
|
width: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
MySpacing.height(16),
|
},
|
||||||
Container(height: 10, width: 150, color: Colors.grey.shade300),
|
);
|
||||||
MySpacing.height(8),
|
}
|
||||||
Container(height: 10, width: 100, color: Colors.grey.shade300),
|
|
||||||
MySpacing.height(8),
|
|
||||||
Container(height: 10, width: 120, color: Colors.grey.shade300),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
static Widget employeeSkeletonCard() {
|
||||||
|
return MyCard.bordered(
|
||||||
|
margin: MySpacing.only(bottom: 12),
|
||||||
|
paddingAll: 12,
|
||||||
|
borderRadiusAll: 12,
|
||||||
|
shadow: MyShadow(
|
||||||
|
elevation: 1.5,
|
||||||
|
position: MyShadowPosition.bottom,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Avatar
|
||||||
|
Container(
|
||||||
|
height: 35,
|
||||||
|
width: 35,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.width(12),
|
||||||
|
|
||||||
|
// Name, org, email, phone
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(height: 12, width: 120, color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(6),
|
||||||
|
Container(height: 10, width: 80, color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(8),
|
||||||
|
|
||||||
|
// Email placeholder
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.email_outlined,
|
||||||
|
size: 14, color: Colors.grey.shade300),
|
||||||
|
MySpacing.width(4),
|
||||||
|
Container(
|
||||||
|
height: 10, width: 140, color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
|
||||||
|
// Phone placeholder
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.phone_outlined,
|
||||||
|
size: 14, color: Colors.grey.shade300),
|
||||||
|
MySpacing.width(4),
|
||||||
|
Container(
|
||||||
|
height: 10, width: 100, color: Colors.grey.shade300),
|
||||||
|
MySpacing.width(8),
|
||||||
|
Container(
|
||||||
|
height: 16,
|
||||||
|
width: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
|
||||||
|
// Tags placeholder
|
||||||
|
Container(height: 8, width: 80, color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Arrow
|
||||||
|
Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Widget contactSkeletonCard() {
|
||||||
|
return MyCard.bordered(
|
||||||
|
margin: MySpacing.only(bottom: 12),
|
||||||
|
paddingAll: 16,
|
||||||
|
borderRadiusAll: 16,
|
||||||
|
shadow: MyShadow(
|
||||||
|
elevation: 1.5,
|
||||||
|
position: MyShadowPosition.bottom,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 40,
|
||||||
|
width: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.width(12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 12,
|
||||||
|
width: 100,
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
MySpacing.height(6),
|
||||||
|
Container(
|
||||||
|
height: 10,
|
||||||
|
width: 60,
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
Container(height: 10, width: 150, color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(8),
|
||||||
|
Container(height: 10, width: 100, color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(8),
|
||||||
|
Container(height: 10, width: 120, color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 Padding(
|
),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
);
|
||||||
child: Row(
|
}
|
||||||
children: [
|
|
||||||
Avatar(firstName: member.firstName, lastName: '', size: 36),
|
return ListView.separated(
|
||||||
const SizedBox(width: 10),
|
shrinkWrap: true,
|
||||||
MyText.bodyMedium(member.firstName, fontWeight: 500),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
],
|
itemCount: teamMembers.length,
|
||||||
),
|
separatorBuilder: (_, __) => const Divider(thickness: 0.8, height: 12),
|
||||||
|
itemBuilder: (_, index) {
|
||||||
|
final member = teamMembers[index];
|
||||||
|
final String name = member.firstName ?? 'Unnamed';
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Avatar(firstName: member.firstName, lastName: '', size: 36),
|
||||||
|
MySpacing.width(10),
|
||||||
|
MyText.bodyMedium(name, fontWeight: 500),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,201 +26,185 @@ 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,
|
||||||
child: DraggableScrollableSheet(
|
canEdit: canEdit,
|
||||||
expand: false,
|
onEdit: onEdit,
|
||||||
initialChildSize: 0.7,
|
ownerId: ownerId,
|
||||||
minChildSize: 0.5,
|
),
|
||||||
maxChildSize: 0.95,
|
);
|
||||||
builder: (context, scrollController) {
|
},
|
||||||
return Column(
|
);
|
||||||
children: [
|
}
|
||||||
const SizedBox(height: 6),
|
}
|
||||||
Container(
|
|
||||||
width: 36,
|
class _TeamContent extends StatelessWidget {
|
||||||
height: 4,
|
final ContactBucket bucket;
|
||||||
decoration: BoxDecoration(
|
final List<dynamic> members;
|
||||||
color: Colors.grey.shade300,
|
final bool canEdit;
|
||||||
borderRadius: BorderRadius.circular(2),
|
final VoidCallback? onEdit;
|
||||||
),
|
final String ownerId;
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
const _TeamContent({
|
||||||
|
required this.bucket,
|
||||||
MyText.titleMedium(
|
required this.members,
|
||||||
'Bucket Details',
|
required this.canEdit,
|
||||||
fontWeight: 700,
|
this.onEdit,
|
||||||
),
|
required this.ownerId,
|
||||||
|
});
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
@override
|
||||||
// Header with title and edit
|
Widget build(BuildContext context) {
|
||||||
Padding(
|
return Column(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
children: [
|
||||||
child: Row(
|
_buildHeader(),
|
||||||
children: [
|
_buildInfo(),
|
||||||
Expanded(
|
_buildMembersTitle(),
|
||||||
child: MyText.titleMedium(
|
MySpacing.height(8),
|
||||||
bucket.name,
|
SizedBox(
|
||||||
fontWeight: 700,
|
height: 300,
|
||||||
),
|
child: _buildMemberList(),
|
||||||
),
|
),
|
||||||
if (canEdit)
|
],
|
||||||
IconButton(
|
);
|
||||||
onPressed: onEdit,
|
}
|
||||||
icon: const Icon(Icons.edit, color: Colors.red),
|
|
||||||
tooltip: 'Edit Bucket',
|
Widget _buildHeader() {
|
||||||
),
|
return Padding(
|
||||||
],
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
),
|
child: Row(
|
||||||
),
|
children: [
|
||||||
|
Expanded(
|
||||||
// Info
|
child: MyText.titleMedium(bucket.name, fontWeight: 700),
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
if (canEdit)
|
||||||
child: Column(
|
IconButton(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
onPressed: onEdit,
|
||||||
children: [
|
icon: const Icon(Icons.edit, color: Colors.red),
|
||||||
if (bucket.description.isNotEmpty)
|
tooltip: 'Edit Bucket',
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
],
|
||||||
child: MyText.bodySmall(
|
),
|
||||||
bucket.description,
|
);
|
||||||
color: Colors.grey[700],
|
}
|
||||||
),
|
|
||||||
),
|
Widget _buildInfo() {
|
||||||
Row(
|
return Padding(
|
||||||
children: [
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
const Icon(Icons.contacts_outlined,
|
child: Column(
|
||||||
size: 14, color: Colors.grey),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(width: 4),
|
children: [
|
||||||
MyText.labelSmall(
|
if (bucket.description.isNotEmpty)
|
||||||
'${bucket.numberOfContacts} contact(s)',
|
Padding(
|
||||||
fontWeight: 600,
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
color: Colors.red,
|
child: MyText.bodySmall(
|
||||||
),
|
bucket.description,
|
||||||
const SizedBox(width: 12),
|
color: Colors.grey[700],
|
||||||
const Icon(Icons.ios_share_outlined,
|
),
|
||||||
size: 14, color: Colors.grey),
|
),
|
||||||
const SizedBox(width: 4),
|
Row(
|
||||||
MyText.labelSmall(
|
children: [
|
||||||
'Shared with (${members.length})',
|
const Icon(Icons.contacts_outlined, size: 14, color: Colors.grey),
|
||||||
fontWeight: 600,
|
const SizedBox(width: 4),
|
||||||
color: Colors.indigo,
|
MyText.labelSmall(
|
||||||
),
|
'${bucket.numberOfContacts} contact(s)',
|
||||||
],
|
fontWeight: 600,
|
||||||
),
|
color: Colors.red,
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.only(top: 8),
|
const SizedBox(width: 12),
|
||||||
child: Row(
|
const Icon(Icons.ios_share_outlined, size: 14, color: Colors.grey),
|
||||||
children: [
|
const SizedBox(width: 4),
|
||||||
const Icon(Icons.edit_outlined,
|
MyText.labelSmall(
|
||||||
size: 14, color: Colors.grey),
|
'Shared with (${members.length})',
|
||||||
const SizedBox(width: 4),
|
fontWeight: 600,
|
||||||
MyText.labelSmall(
|
color: Colors.indigo,
|
||||||
canEdit
|
),
|
||||||
? 'Can be edited by you'
|
],
|
||||||
: 'You don’t have edit access',
|
),
|
||||||
fontWeight: 600,
|
MySpacing.height(8),
|
||||||
color: canEdit ? Colors.green : Colors.grey,
|
Row(
|
||||||
),
|
children: [
|
||||||
],
|
const Icon(Icons.edit_outlined, size: 14, color: Colors.grey),
|
||||||
),
|
const SizedBox(width: 4),
|
||||||
),
|
MyText.labelSmall(
|
||||||
const SizedBox(height: 8),
|
canEdit ? 'Can be edited by you' : 'You don’t have edit access',
|
||||||
const Divider(thickness: 1),
|
fontWeight: 600,
|
||||||
const SizedBox(height: 6),
|
color: canEdit ? Colors.green : Colors.grey,
|
||||||
MyText.labelLarge(
|
),
|
||||||
'Shared with',
|
],
|
||||||
fontWeight: 700,
|
),
|
||||||
color: Colors.black,
|
MySpacing.height(12),
|
||||||
),
|
const Divider(thickness: 1),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
const SizedBox(height: 4),
|
|
||||||
|
Widget _buildMembersTitle() {
|
||||||
Expanded(
|
return Align(
|
||||||
child: Padding(
|
alignment: Alignment.centerLeft,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
child: MyText.labelLarge('Shared with', fontWeight: 700, color: Colors.black),
|
||||||
child: members.isEmpty
|
);
|
||||||
? Center(
|
}
|
||||||
child: MyText.bodySmall(
|
|
||||||
"No team members found.",
|
Widget _buildMemberList() {
|
||||||
fontWeight: 600,
|
if (members.isEmpty) {
|
||||||
color: Colors.grey,
|
return Center(
|
||||||
),
|
child: MyText.bodySmall(
|
||||||
)
|
"No team members found.",
|
||||||
: ListView.separated(
|
fontWeight: 600,
|
||||||
controller: scrollController,
|
color: Colors.grey,
|
||||||
itemCount: members.length,
|
),
|
||||||
separatorBuilder: (_, __) =>
|
);
|
||||||
const SizedBox(height: 4),
|
}
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final member = members[index];
|
return ListView.separated(
|
||||||
final firstName = member.firstName ?? '';
|
itemCount: members.length,
|
||||||
final lastName = member.lastName ?? '';
|
separatorBuilder: (_, __) => const SizedBox(height: 6),
|
||||||
final isOwner =
|
itemBuilder: (context, index) {
|
||||||
member.id == bucket.createdBy.id;
|
final member = members[index];
|
||||||
|
final firstName = member.firstName ?? '';
|
||||||
return ListTile(
|
final lastName = member.lastName ?? '';
|
||||||
dense: true,
|
final isOwner = member.id == ownerId;
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
leading: Avatar(
|
return ListTile(
|
||||||
firstName: firstName,
|
dense: true,
|
||||||
lastName: lastName,
|
contentPadding: EdgeInsets.zero,
|
||||||
size: 32,
|
leading: Avatar(firstName: firstName, lastName: lastName, size: 32),
|
||||||
),
|
title: Row(
|
||||||
title: Row(
|
children: [
|
||||||
children: [
|
Expanded(
|
||||||
Expanded(
|
child: MyText.bodyMedium(
|
||||||
child: MyText.bodyMedium(
|
'${firstName.isNotEmpty ? firstName : 'Unnamed'} ${lastName.isNotEmpty ? lastName : ''}',
|
||||||
'${firstName.isNotEmpty ? firstName : 'Unnamed'} ${lastName.isNotEmpty ? lastName : ''}',
|
fontWeight: 600,
|
||||||
fontWeight: 600,
|
),
|
||||||
),
|
),
|
||||||
),
|
if (isOwner)
|
||||||
if (isOwner)
|
Container(
|
||||||
Container(
|
margin: const EdgeInsets.only(left: 6),
|
||||||
margin:
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
const EdgeInsets.only(left: 6),
|
decoration: BoxDecoration(
|
||||||
padding: const EdgeInsets.symmetric(
|
color: Colors.red.shade50,
|
||||||
horizontal: 6, vertical: 2),
|
borderRadius: BorderRadius.circular(4),
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: Colors.red.shade50,
|
child: MyText.labelSmall(
|
||||||
borderRadius:
|
"Owner",
|
||||||
BorderRadius.circular(4),
|
fontWeight: 600,
|
||||||
),
|
color: Colors.red,
|
||||||
child: MyText.labelSmall(
|
),
|
||||||
"Owner",
|
),
|
||||||
fontWeight: 600,
|
],
|
||||||
color: Colors.red,
|
),
|
||||||
),
|
subtitle: MyText.bodySmall(
|
||||||
),
|
member.jobRole ?? '',
|
||||||
],
|
color: Colors.grey.shade600,
|
||||||
),
|
|
||||||
subtitle: MyText.bodySmall(
|
|
||||||
member.jobRole ?? '',
|
|
||||||
color: Colors.grey.shade600,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
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';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@ -18,11 +21,12 @@ Future<void> main() async {
|
|||||||
runApp(
|
runApp(
|
||||||
ChangeNotifierProvider<AppNotifier>(
|
ChangeNotifierProvider<AppNotifier>(
|
||||||
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,
|
||||||
@ -42,3 +46,57 @@ Future<void> main() async {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This widget listens to connectivity changes and switches between
|
||||||
|
/// `MyApp` and `OfflineScreen` automatically.
|
||||||
|
class MainWrapper extends StatefulWidget {
|
||||||
|
const MainWrapper({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MainWrapper> createState() => _MainWrapperState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainWrapperState extends State<MainWrapper> {
|
||||||
|
// Use a List to store connectivity status as the API now returns a list
|
||||||
|
List<ConnectivityResult> _connectivityStatus = [ConnectivityResult.none];
|
||||||
|
final Connectivity _connectivity = Connectivity();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeConnectivity();
|
||||||
|
// Listen for changes, the callback now provides a List<ConnectivityResult>
|
||||||
|
_connectivity.onConnectivityChanged
|
||||||
|
.listen((List<ConnectivityResult> results) {
|
||||||
|
setState(() {
|
||||||
|
_connectivityStatus = results;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initializeConnectivity() async {
|
||||||
|
// checkConnectivity() now returns a List<ConnectivityResult>
|
||||||
|
final result = await _connectivity.checkConnectivity();
|
||||||
|
setState(() {
|
||||||
|
_connectivityStatus = result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Check if any of the connectivity results indicate no internet
|
||||||
|
final bool isOffline =
|
||||||
|
_connectivityStatus.contains(ConnectivityResult.none);
|
||||||
|
|
||||||
|
// Show OfflineScreen if no internet
|
||||||
|
if (isOffline) {
|
||||||
|
return const MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: OfflineScreen(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show main app if online
|
||||||
|
return const MyApp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,117 +1,27 @@
|
|||||||
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/dashboard/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();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _showCommentBottomSheet(
|
|
||||||
BuildContext context, String actionText) async {
|
|
||||||
final TextEditingController commentController = TextEditingController();
|
|
||||||
String? errorText;
|
|
||||||
Get.find<ProjectController>().selectedProject?.id;
|
|
||||||
return showModalBottomSheet<String>(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
||||||
),
|
|
||||||
builder: (context) {
|
|
||||||
return StatefulBuilder(
|
|
||||||
builder: (context, setModalState) {
|
|
||||||
return Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
top: 24,
|
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Add Comment for ${capitalizeFirstLetter(actionText)}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextField(
|
|
||||||
controller: commentController,
|
|
||||||
maxLines: 4,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Type your comment here...',
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
filled: true,
|
|
||||||
fillColor: Colors.grey.shade100,
|
|
||||||
errorText: errorText,
|
|
||||||
),
|
|
||||||
onChanged: (_) {
|
|
||||||
if (errorText != null) {
|
|
||||||
setModalState(() => errorText = null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
final comment = commentController.text.trim();
|
|
||||||
if (comment.isEmpty) {
|
|
||||||
setModalState(() {
|
|
||||||
errorText = 'Comment cannot be empty.';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Navigator.of(context).pop(comment);
|
|
||||||
},
|
|
||||||
child: const Text('Submit'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String capitalizeFirstLetter(String text) {
|
|
||||||
if (text.isEmpty) return text;
|
|
||||||
return text[0].toUpperCase() + text.substring(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
||||||
late final String uniqueLogKey;
|
late final String uniqueLogKey;
|
||||||
|
|
||||||
@ -119,60 +29,57 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
uniqueLogKey = AttendanceButtonHelper.getUniqueKey(
|
uniqueLogKey = AttendanceButtonHelper.getUniqueKey(
|
||||||
widget.employee.employeeId, widget.employee.id);
|
widget.employee.employeeId,
|
||||||
|
widget.employee.id,
|
||||||
|
);
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!widget.attendanceController.uploadingStates
|
widget.attendanceController.uploadingStates.putIfAbsent(
|
||||||
.containsKey(uniqueLogKey)) {
|
uniqueLogKey,
|
||||||
widget.attendanceController.uploadingStates[uniqueLogKey] = false.obs;
|
() => false.obs,
|
||||||
}
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<DateTime?> showTimePickerForRegularization({
|
Future<DateTime?> _pickRegularizationTime(DateTime checkInTime) async {
|
||||||
required BuildContext context,
|
|
||||||
required DateTime checkInTime,
|
|
||||||
}) async {
|
|
||||||
final pickedTime = await showTimePicker(
|
final pickedTime = await showTimePicker(
|
||||||
context: context,
|
context: context,
|
||||||
initialTime: TimeOfDay.fromDateTime(DateTime.now()),
|
initialTime: TimeOfDay.fromDateTime(DateTime.now()),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (pickedTime != null) {
|
if (pickedTime == null) return null;
|
||||||
final selectedDateTime = DateTime(
|
|
||||||
checkInTime.year,
|
final selected = DateTime(
|
||||||
checkInTime.month,
|
checkInTime.year,
|
||||||
checkInTime.day,
|
checkInTime.month,
|
||||||
pickedTime.hour,
|
checkInTime.day,
|
||||||
pickedTime.minute,
|
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;
|
||||||
final now = DateTime.now();
|
} else if (selected.isAfter(now)) {
|
||||||
|
showAppSnackbar(
|
||||||
if (selectedDateTime.isBefore(checkInTime)) {
|
title: "Invalid Time",
|
||||||
showAppSnackbar(
|
message: "Future time is not allowed.",
|
||||||
title: "Invalid Time",
|
type: SnackbarType.warning,
|
||||||
message: "Time must be after check-in.",
|
);
|
||||||
type: SnackbarType.warning,
|
return null;
|
||||||
);
|
|
||||||
return null;
|
|
||||||
} else if (selectedDateTime.isAfter(now)) {
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Invalid Time",
|
|
||||||
message: "Future time is not allowed.",
|
|
||||||
type: SnackbarType.warning,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return selectedDateTime;
|
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
return selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleButtonPressed(BuildContext context) async {
|
Future<void> _handleButtonPressed() async {
|
||||||
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = true;
|
final controller = widget.attendanceController;
|
||||||
|
|
||||||
final projectController = Get.find<ProjectController>();
|
final projectController = Get.find<ProjectController>();
|
||||||
final selectedProjectId = projectController.selectedProject?.id;
|
final selectedProjectId = projectController.selectedProject?.id;
|
||||||
|
|
||||||
@ -182,53 +89,49 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
|||||||
message: "Please select a project first",
|
message: "Please select a project first",
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int updatedAction;
|
controller.uploadingStates[uniqueLogKey]?.value = true;
|
||||||
|
|
||||||
|
int action;
|
||||||
String actionText;
|
String actionText;
|
||||||
bool imageCapture = true;
|
bool imageCapture = true;
|
||||||
|
|
||||||
switch (widget.employee.activity) {
|
switch (widget.employee.activity) {
|
||||||
case 0:
|
case 0:
|
||||||
updatedAction = 0;
|
case 4:
|
||||||
|
action = 0;
|
||||||
actionText = ButtonActions.checkIn;
|
actionText = ButtonActions.checkIn;
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
if (widget.employee.checkOut == null &&
|
final isOld = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2);
|
||||||
AttendanceButtonHelper.isOlderThanDays(
|
final isOldCheckout = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2);
|
||||||
widget.employee.checkIn, 2)) {
|
|
||||||
updatedAction = 2;
|
if (widget.employee.checkOut == null && isOld) {
|
||||||
|
action = 2;
|
||||||
actionText = ButtonActions.requestRegularize;
|
actionText = ButtonActions.requestRegularize;
|
||||||
imageCapture = false;
|
imageCapture = false;
|
||||||
} else if (widget.employee.checkOut != null &&
|
} else if (widget.employee.checkOut != null && isOldCheckout) {
|
||||||
AttendanceButtonHelper.isOlderThanDays(
|
action = 2;
|
||||||
widget.employee.checkOut, 2)) {
|
|
||||||
updatedAction = 2;
|
|
||||||
actionText = ButtonActions.requestRegularize;
|
actionText = ButtonActions.requestRegularize;
|
||||||
} else {
|
} else {
|
||||||
updatedAction = 1;
|
action = 1;
|
||||||
actionText = ButtonActions.checkOut;
|
actionText = ButtonActions.checkOut;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
updatedAction = 2;
|
action = 2;
|
||||||
actionText = ButtonActions.requestRegularize;
|
actionText = ButtonActions.requestRegularize;
|
||||||
break;
|
break;
|
||||||
case 4:
|
|
||||||
updatedAction = 0;
|
|
||||||
actionText = ButtonActions.checkIn;
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
updatedAction = 0;
|
action = 0;
|
||||||
actionText = "Unknown Action";
|
actionText = "Unknown Action";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
DateTime? selectedTime;
|
DateTime? selectedTime;
|
||||||
|
|
||||||
// ✅ New condition: Yesterday Check-In + CheckOut action
|
|
||||||
final isYesterdayCheckIn = widget.employee.checkIn != null &&
|
final isYesterdayCheckIn = widget.employee.checkIn != null &&
|
||||||
DateUtils.isSameDay(
|
DateUtils.isSameDay(
|
||||||
widget.employee.checkIn,
|
widget.employee.checkIn,
|
||||||
@ -238,67 +141,41 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
|||||||
if (isYesterdayCheckIn &&
|
if (isYesterdayCheckIn &&
|
||||||
widget.employee.checkOut == null &&
|
widget.employee.checkOut == null &&
|
||||||
actionText == ButtonActions.checkOut) {
|
actionText == ButtonActions.checkOut) {
|
||||||
selectedTime = await showTimePickerForRegularization(
|
selectedTime = await _pickRegularizationTime(widget.employee.checkIn!);
|
||||||
context: context,
|
|
||||||
checkInTime: widget.employee.checkIn!,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selectedTime == null) {
|
if (selectedTime == null) {
|
||||||
widget.attendanceController.uploadingStates[uniqueLogKey]?.value =
|
controller.uploadingStates[uniqueLogKey]?.value = false;
|
||||||
false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final userComment = await _showCommentBottomSheet(context, actionText);
|
final comment = await _showCommentBottomSheet(context, actionText);
|
||||||
if (userComment == null || userComment.isEmpty) {
|
if (comment == null || comment.isEmpty) {
|
||||||
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false;
|
controller.uploadingStates[uniqueLogKey]?.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool success = false;
|
bool success = false;
|
||||||
|
String? markTime;
|
||||||
|
|
||||||
if (actionText == ButtonActions.requestRegularize) {
|
if (actionText == ButtonActions.requestRegularize) {
|
||||||
final regularizeTime = selectedTime ??
|
selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!);
|
||||||
await showTimePickerForRegularization(
|
if (selectedTime != null) {
|
||||||
context: context,
|
markTime = DateFormat("hh:mm a").format(selectedTime);
|
||||||
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) {
|
} else if (selectedTime != null) {
|
||||||
// ✅ If selectedTime was picked in the new condition
|
markTime = DateFormat("hh:mm a").format(selectedTime);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
success = await controller.captureAndUploadAttendance(
|
||||||
|
widget.employee.id,
|
||||||
|
widget.employee.employeeId,
|
||||||
|
selectedProjectId,
|
||||||
|
comment: comment,
|
||||||
|
action: action,
|
||||||
|
imageCapture: imageCapture,
|
||||||
|
markTime: markTime,
|
||||||
|
);
|
||||||
|
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: success ? '${capitalizeFirstLetter(actionText)} Success' : 'Error',
|
title: success ? '${capitalizeFirstLetter(actionText)} Success' : 'Error',
|
||||||
message: success
|
message: success
|
||||||
@ -307,51 +184,47 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
|||||||
type: success ? SnackbarType.success : SnackbarType.error,
|
type: success ? SnackbarType.success : SnackbarType.error,
|
||||||
);
|
);
|
||||||
|
|
||||||
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false;
|
controller.uploadingStates[uniqueLogKey]?.value = false;
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
widget.attendanceController.fetchEmployeesByProject(selectedProjectId);
|
controller.fetchEmployeesByProject(selectedProjectId);
|
||||||
widget.attendanceController.fetchAttendanceLogs(selectedProjectId);
|
controller.fetchAttendanceLogs(selectedProjectId);
|
||||||
await widget.attendanceController
|
await controller.fetchRegularizationLogs(selectedProjectId);
|
||||||
.fetchRegularizationLogs(selectedProjectId);
|
await controller.fetchProjectData(selectedProjectId);
|
||||||
await widget.attendanceController.fetchProjectData(selectedProjectId);
|
controller.update();
|
||||||
widget.attendanceController.update();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
final isUploading =
|
final controller = widget.attendanceController;
|
||||||
widget.attendanceController.uploadingStates[uniqueLogKey]?.value ??
|
|
||||||
false;
|
|
||||||
|
|
||||||
final isYesterday = AttendanceButtonHelper.isLogFromYesterday(
|
final isUploading = controller.uploadingStates[uniqueLogKey]?.value ?? false;
|
||||||
widget.employee.checkIn, widget.employee.checkOut);
|
final emp = widget.employee;
|
||||||
final isTodayApproved = AttendanceButtonHelper.isTodayApproved(
|
|
||||||
widget.employee.activity, widget.employee.checkIn);
|
final isYesterday = AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut);
|
||||||
final isApprovedButNotToday =
|
final isTodayApproved = AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn);
|
||||||
AttendanceButtonHelper.isApprovedButNotToday(
|
final isApprovedButNotToday = AttendanceButtonHelper.isApprovedButNotToday(emp.activity, isTodayApproved);
|
||||||
widget.employee.activity, isTodayApproved);
|
|
||||||
|
|
||||||
final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled(
|
final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled(
|
||||||
isUploading: isUploading,
|
isUploading: isUploading,
|
||||||
isYesterday: isYesterday,
|
isYesterday: isYesterday,
|
||||||
activity: widget.employee.activity,
|
activity: emp.activity,
|
||||||
isApprovedButNotToday: isApprovedButNotToday,
|
isApprovedButNotToday: isApprovedButNotToday,
|
||||||
);
|
);
|
||||||
|
|
||||||
final buttonText = AttendanceButtonHelper.getButtonText(
|
final buttonText = AttendanceButtonHelper.getButtonText(
|
||||||
activity: widget.employee.activity,
|
activity: emp.activity,
|
||||||
checkIn: widget.employee.checkIn,
|
checkIn: emp.checkIn,
|
||||||
checkOut: widget.employee.checkOut,
|
checkOut: emp.checkOut,
|
||||||
isTodayApproved: isTodayApproved,
|
isTodayApproved: isTodayApproved,
|
||||||
);
|
);
|
||||||
|
|
||||||
final buttonColor = AttendanceButtonHelper.getButtonColor(
|
final buttonColor = AttendanceButtonHelper.getButtonColor(
|
||||||
isYesterday: isYesterday,
|
isYesterday: isYesterday,
|
||||||
isTodayApproved: isTodayApproved,
|
isTodayApproved: isTodayApproved,
|
||||||
activity: widget.employee.activity,
|
activity: emp.activity,
|
||||||
);
|
);
|
||||||
|
|
||||||
return AttendanceActionButtonUI(
|
return AttendanceActionButtonUI(
|
||||||
@ -359,8 +232,7 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
|||||||
isButtonDisabled: isButtonDisabled,
|
isButtonDisabled: isButtonDisabled,
|
||||||
buttonText: buttonText,
|
buttonText: buttonText,
|
||||||
buttonColor: buttonColor,
|
buttonColor: buttonColor,
|
||||||
onPressed:
|
onPressed: isButtonDisabled ? null : _handleButtonPressed,
|
||||||
isButtonDisabled ? null : () => _handleButtonPressed(context),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -374,20 +246,20 @@ class AttendanceActionButtonUI extends StatelessWidget {
|
|||||||
final VoidCallback? onPressed;
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
const AttendanceActionButtonUI({
|
const AttendanceActionButtonUI({
|
||||||
Key? key,
|
super.key,
|
||||||
required this.isUploading,
|
required this.isUploading,
|
||||||
required this.isButtonDisabled,
|
required this.isButtonDisabled,
|
||||||
required this.buttonText,
|
required this.buttonText,
|
||||||
required this.buttonColor,
|
required this.buttonColor,
|
||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
}) : super(key: key);
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 30,
|
height: 30,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: isButtonDisabled ? null : onPressed,
|
onPressed: onPressed,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: buttonColor,
|
backgroundColor: buttonColor,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
||||||
@ -405,17 +277,14 @@ class AttendanceActionButtonUI extends StatelessWidget {
|
|||||||
: Row(
|
: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (buttonText.toLowerCase() == 'approved') ...[
|
if (buttonText.toLowerCase() == 'approved')
|
||||||
const Icon(Icons.check, size: 16, color: Colors.green),
|
const Icon(Icons.check, size: 16, color: Colors.green),
|
||||||
const SizedBox(width: 4),
|
if (buttonText.toLowerCase() == 'rejected')
|
||||||
] else if (buttonText.toLowerCase() == 'rejected') ...[
|
|
||||||
const Icon(Icons.close, size: 16, color: Colors.red),
|
const Icon(Icons.close, size: 16, color: Colors.red),
|
||||||
|
if (buttonText.toLowerCase() == 'requested')
|
||||||
|
const Icon(Icons.hourglass_top, size: 16, color: Colors.orange),
|
||||||
|
if (['approved', 'rejected', 'requested'].contains(buttonText.toLowerCase()))
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
] else if (buttonText.toLowerCase() == 'requested') ...[
|
|
||||||
const Icon(Icons.hourglass_top,
|
|
||||||
size: 16, color: Colors.orange),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
],
|
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
buttonText,
|
buttonText,
|
||||||
@ -429,3 +298,68 @@ class AttendanceActionButtonUI extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String?> _showCommentBottomSheet(BuildContext context, String actionText) async {
|
||||||
|
final commentController = TextEditingController();
|
||||||
|
String? errorText;
|
||||||
|
|
||||||
|
return showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (context) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setModalState) {
|
||||||
|
void submit() {
|
||||||
|
final comment = commentController.text.trim();
|
||||||
|
if (comment.isEmpty) {
|
||||||
|
setModalState(() => errorText = 'Comment cannot be empty.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||||
|
child: BaseBottomSheet(
|
||||||
|
title: 'Add Comment for ${capitalizeFirstLetter(actionText)}',
|
||||||
|
onCancel: () => Navigator.of(context).pop(),
|
||||||
|
onSubmit: submit,
|
||||||
|
isSubmitting: false,
|
||||||
|
submitText: 'Submit',
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: commentController,
|
||||||
|
maxLines: 4,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Type your comment here...',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
errorText: errorText,
|
||||||
|
),
|
||||||
|
onChanged: (_) {
|
||||||
|
if (errorText != null) {
|
||||||
|
setModalState(() => errorText = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String capitalizeFirstLetter(String text) =>
|
||||||
|
text.isEmpty ? text : text[0].toUpperCase() + text.substring(1);
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:marco/controller/permission_controller.dart';
|
import 'package:marco/controller/permission_controller.dart';
|
||||||
import 'package:marco/controller/dashboard/attendance_screen_controller.dart';
|
import 'package:marco/controller/dashboard/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';
|
||||||
|
|
||||||
class AttendanceFilterBottomSheet extends StatefulWidget {
|
class AttendanceFilterBottomSheet extends StatefulWidget {
|
||||||
final AttendanceController controller;
|
final AttendanceController controller;
|
||||||
@ -18,7 +19,7 @@ class AttendanceFilterBottomSheet extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_AttendanceFilterBottomSheetState createState() =>
|
State<AttendanceFilterBottomSheet> createState() =>
|
||||||
_AttendanceFilterBottomSheetState();
|
_AttendanceFilterBottomSheetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,83 +54,70 @@ 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: 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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
...filteredViewOptions.map((item) {
|
...filteredOptions.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(),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
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: 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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
InkWell(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: InkWell(
|
onTap: () => widget.controller.selectDateRangeForAttendance(
|
||||||
borderRadius: BorderRadius.circular(10),
|
context,
|
||||||
onTap: () => widget.controller.selectDateRangeForAttendance(
|
widget.controller,
|
||||||
context,
|
),
|
||||||
widget.controller,
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: Colors.grey.shade400),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: Ink(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
decoration: BoxDecoration(
|
child: Row(
|
||||||
color: Colors.white,
|
children: [
|
||||||
border: Border.all(color: Colors.grey.shade400),
|
const Icon(Icons.date_range, color: Colors.black87),
|
||||||
borderRadius: BorderRadius.circular(10),
|
const SizedBox(width: 12),
|
||||||
),
|
Expanded(
|
||||||
padding:
|
child: MyText.bodyMedium(
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
getLabelText(),
|
||||||
child: Row(
|
fontWeight: 500,
|
||||||
children: [
|
color: Colors.black87,
|
||||||
Icon(Icons.date_range, color: Colors.black87),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
getLabelText(),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
color: Colors.black87,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const Icon(Icons.arrow_drop_down, color: Colors.black87),
|
),
|
||||||
],
|
const Icon(Icons.arrow_drop_down, color: Colors.black87),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -141,49 +129,17 @@ 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(
|
||||||
|
title: "Attendance Filter",
|
||||||
|
onCancel: () => Navigator.pop(context),
|
||||||
|
onSubmit: () => Navigator.pop(context, {
|
||||||
|
'selectedTab': tempSelectedTab,
|
||||||
|
}),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: buildMainFilters(),
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 12, bottom: 8),
|
|
||||||
child: Center(
|
|
||||||
child: Container(
|
|
||||||
width: 40,
|
|
||||||
height: 4,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[400],
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
...buildMainFilters(),
|
|
||||||
const Divider(),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
child: SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: const Color.fromARGB(255, 95, 132, 255),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Text('Apply Filter'),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context, {
|
|
||||||
'selectedTab': tempSelectedTab,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,11 +2,11 @@ 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/attendance_actions.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
|
||||||
class AttendanceLogViewButton extends StatelessWidget {
|
class AttendanceLogViewButton extends StatelessWidget {
|
||||||
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,
|
||||||
required this.employee,
|
required this.employee,
|
||||||
@ -50,191 +50,164 @@ class AttendanceLogViewButton extends StatelessWidget {
|
|||||||
|
|
||||||
void _showLogsBottomSheet(BuildContext context) async {
|
void _showLogsBottomSheet(BuildContext context) async {
|
||||||
await attendanceController.fetchLogsView(employee.id.toString());
|
await attendanceController.fetchLogsView(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) => BaseBottomSheet(
|
||||||
padding: EdgeInsets.only(
|
title: "Attendance Log",
|
||||||
left: 16,
|
onCancel: () => Navigator.pop(context),
|
||||||
right: 16,
|
onSubmit: () => Navigator.pop(context),
|
||||||
top: 16,
|
showButtons: false,
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
child: attendanceController.attendenceLogsView.isEmpty
|
||||||
),
|
? Padding(
|
||||||
child: SingleChildScrollView(
|
padding: const EdgeInsets.symmetric(vertical: 24.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: const [
|
||||||
children: [
|
Icon(Icons.info_outline, size: 40, color: Colors.grey),
|
||||||
// Header
|
SizedBox(height: 8),
|
||||||
Row(
|
Text("No attendance logs available."),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
],
|
||||||
children: [
|
),
|
||||||
MyText.titleMedium(
|
)
|
||||||
"Attendance Log",
|
: ListView.separated(
|
||||||
fontWeight: 700,
|
shrinkWrap: true,
|
||||||
),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
IconButton(
|
itemCount: attendanceController.attendenceLogsView.length,
|
||||||
icon: const Icon(Icons.close),
|
separatorBuilder: (_, __) => const SizedBox(height: 16),
|
||||||
onPressed: () => Navigator.pop(context),
|
itemBuilder: (_, index) {
|
||||||
),
|
final log = attendanceController.attendenceLogsView[index];
|
||||||
],
|
return Container(
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
const SizedBox(height: 12),
|
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||||
if (attendanceController.attendenceLogsView.isEmpty)
|
borderRadius: BorderRadius.circular(12),
|
||||||
Padding(
|
boxShadow: [
|
||||||
padding: const EdgeInsets.symmetric(vertical: 24.0),
|
BoxShadow(
|
||||||
child: Column(
|
color: Colors.black.withOpacity(0.05),
|
||||||
children: const [
|
blurRadius: 6,
|
||||||
Icon(Icons.info_outline, size: 40, color: Colors.grey),
|
offset: const Offset(0, 2),
|
||||||
SizedBox(height: 8),
|
)
|
||||||
Text("No attendance logs available."),
|
],
|
||||||
],
|
),
|
||||||
),
|
padding: const EdgeInsets.all(8),
|
||||||
)
|
child: Column(
|
||||||
else
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
ListView.separated(
|
children: [
|
||||||
shrinkWrap: true,
|
Row(
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
itemCount: attendanceController.attendenceLogsView.length,
|
children: [
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 16),
|
Expanded(
|
||||||
itemBuilder: (_, index) {
|
flex: 3,
|
||||||
final log = attendanceController.attendenceLogsView[index];
|
child: Column(
|
||||||
return Container(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
Row(
|
||||||
borderRadius: BorderRadius.circular(12),
|
children: [
|
||||||
boxShadow: [
|
_getLogIcon(log),
|
||||||
BoxShadow(
|
const SizedBox(width: 10),
|
||||||
color: Colors.black.withOpacity(0.05),
|
Column(
|
||||||
blurRadius: 6,
|
crossAxisAlignment:
|
||||||
offset: const Offset(0, 2),
|
CrossAxisAlignment.start,
|
||||||
)
|
children: [
|
||||||
],
|
MyText.bodyLarge(
|
||||||
),
|
log.formattedDate ?? '-',
|
||||||
padding: const EdgeInsets.all(8),
|
fontWeight: 600,
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
_getLogIcon(log),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment:
|
|
||||||
CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MyText.bodyLarge(
|
|
||||||
log.formattedDate ?? '-',
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
MyText.bodySmall(
|
|
||||||
"Time: ${log.formattedTime ?? '-'}",
|
|
||||||
color: Colors.grey[700],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment:
|
|
||||||
CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (log.latitude != null &&
|
|
||||||
log.longitude != null)
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
final lat = double.tryParse(log
|
|
||||||
.latitude
|
|
||||||
.toString()) ??
|
|
||||||
0.0;
|
|
||||||
final lon = double.tryParse(log
|
|
||||||
.longitude
|
|
||||||
.toString()) ??
|
|
||||||
0.0;
|
|
||||||
if (lat >= -90 &&
|
|
||||||
lat <= 90 &&
|
|
||||||
lon >= -180 &&
|
|
||||||
lon <= 180) {
|
|
||||||
_openGoogleMaps(
|
|
||||||
context, lat, lon);
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Invalid location coordinates')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Padding(
|
|
||||||
padding:
|
|
||||||
EdgeInsets.only(right: 8.0),
|
|
||||||
child: Icon(Icons.location_on,
|
|
||||||
size: 18, color: Colors.blue),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
MyText.bodySmall(
|
||||||
child: MyText.bodyMedium(
|
"Time: ${log.formattedTime ?? '-'}",
|
||||||
log.comment?.isNotEmpty == true
|
color: Colors.grey[700],
|
||||||
? log.comment
|
|
||||||
: "No description provided",
|
|
||||||
fontWeight: 500,
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
if (log.thumbPreSignedUrl != null)
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
if (log.preSignedUrl != null) {
|
|
||||||
_showImageDialog(
|
|
||||||
context, log.preSignedUrl!);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: Image.network(
|
|
||||||
log.thumbPreSignedUrl!,
|
|
||||||
height: 60,
|
|
||||||
width: 60,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
errorBuilder:
|
|
||||||
(context, error, stackTrace) {
|
|
||||||
return const Icon(Icons.broken_image,
|
|
||||||
size: 20, color: Colors.grey);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
const SizedBox(height: 12),
|
||||||
else
|
Row(
|
||||||
const Icon(Icons.broken_image,
|
crossAxisAlignment:
|
||||||
size: 20, color: Colors.grey),
|
CrossAxisAlignment.start,
|
||||||
],
|
children: [
|
||||||
),
|
if (log.latitude != null &&
|
||||||
],
|
log.longitude != null)
|
||||||
),
|
GestureDetector(
|
||||||
);
|
onTap: () {
|
||||||
},
|
final lat = double.tryParse(
|
||||||
)
|
log.latitude.toString()) ??
|
||||||
],
|
0.0;
|
||||||
),
|
final lon = double.tryParse(
|
||||||
),
|
log.longitude.toString()) ??
|
||||||
|
0.0;
|
||||||
|
if (lat >= -90 &&
|
||||||
|
lat <= 90 &&
|
||||||
|
lon >= -180 &&
|
||||||
|
lon <= 180) {
|
||||||
|
_openGoogleMaps(
|
||||||
|
context, lat, lon);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Invalid location coordinates')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Padding(
|
||||||
|
padding:
|
||||||
|
EdgeInsets.only(right: 8.0),
|
||||||
|
child: Icon(Icons.location_on,
|
||||||
|
size: 18, color: Colors.blue),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: MyText.bodyMedium(
|
||||||
|
log.comment?.isNotEmpty == true
|
||||||
|
? log.comment
|
||||||
|
: "No description provided",
|
||||||
|
fontWeight: 500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
if (log.thumbPreSignedUrl != null)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (log.preSignedUrl != null) {
|
||||||
|
_showImageDialog(
|
||||||
|
context, log.preSignedUrl!);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.network(
|
||||||
|
log.thumbPreSignedUrl!,
|
||||||
|
height: 60,
|
||||||
|
width: 60,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return const Icon(Icons.broken_image,
|
||||||
|
size: 20, color: Colors.grey);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Icon(Icons.broken_image,
|
||||||
|
size: 20, color: Colors.grey),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:marco/controller/task_planing/daily_task_planing_controller.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';
|
import 'package:marco/controller/project_controller.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
|
||||||
class AssignTaskBottomSheet extends StatefulWidget {
|
class AssignTaskBottomSheet extends StatefulWidget {
|
||||||
final String workLocation;
|
final String workLocation;
|
||||||
@ -37,17 +38,9 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
|
|||||||
final ProjectController projectController = Get.find();
|
final ProjectController projectController = Get.find();
|
||||||
final TextEditingController targetController = TextEditingController();
|
final TextEditingController targetController = TextEditingController();
|
||||||
final TextEditingController descriptionController = TextEditingController();
|
final TextEditingController descriptionController = TextEditingController();
|
||||||
String? selectedProjectId;
|
|
||||||
|
|
||||||
final ScrollController _employeeListScrollController = ScrollController();
|
final ScrollController _employeeListScrollController = ScrollController();
|
||||||
|
|
||||||
@override
|
String? selectedProjectId;
|
||||||
void dispose() {
|
|
||||||
_employeeListScrollController.dispose();
|
|
||||||
targetController.dispose();
|
|
||||||
descriptionController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -61,180 +54,102 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_employeeListScrollController.dispose();
|
||||||
|
targetController.dispose();
|
||||||
|
descriptionController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return Obx(() => BaseBottomSheet(
|
||||||
child: Container(
|
title: "Assign Task",
|
||||||
padding: MediaQuery.of(context).viewInsets.add(MySpacing.all(16)),
|
child: _buildAssignTaskForm(),
|
||||||
decoration: const BoxDecoration(
|
onCancel: () => Get.back(),
|
||||||
color: Colors.white,
|
onSubmit: _onAssignTaskPressed,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
isSubmitting: controller.isAssigningTask.value,
|
||||||
),
|
));
|
||||||
child: SingleChildScrollView(
|
}
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Widget _buildAssignTaskForm() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_infoRow(Icons.location_on, "Work Location",
|
||||||
|
"${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"),
|
||||||
|
Divider(),
|
||||||
|
_infoRow(Icons.pending_actions, "Pending Task of Activity",
|
||||||
|
"${widget.pendingTask}"),
|
||||||
|
Divider(),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _onRoleMenuPressed,
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
MyText.titleMedium("Select Team :", fontWeight: 600),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
const SizedBox(width: 4),
|
||||||
children: [
|
const Icon(Icons.tune, color: Color.fromARGB(255, 95, 132, 255)),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
MySpacing.height(8),
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 150),
|
||||||
|
child: _buildEmployeeList(),
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
_buildSelectedEmployees(),
|
||||||
|
_buildTextField(
|
||||||
|
icon: Icons.track_changes,
|
||||||
|
label: "Target for Today :",
|
||||||
|
controller: targetController,
|
||||||
|
hintText: "Enter target",
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
validatorType: "target",
|
||||||
|
),
|
||||||
|
MySpacing.height(24),
|
||||||
|
_buildTextField(
|
||||||
|
icon: Icons.description,
|
||||||
|
label: "Description :",
|
||||||
|
controller: descriptionController,
|
||||||
|
hintText: "Enter task description",
|
||||||
|
maxLines: 3,
|
||||||
|
validatorType: "description",
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onRoleMenuPressed() {
|
||||||
|
final RenderBox overlay =
|
||||||
|
Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||||
|
final Size screenSize = overlay.size;
|
||||||
|
|
||||||
|
showMenu(
|
||||||
|
context: context,
|
||||||
|
position: RelativeRect.fromLTRB(
|
||||||
|
screenSize.width / 2 - 100,
|
||||||
|
screenSize.height / 2 - 20,
|
||||||
|
screenSize.width / 2 - 100,
|
||||||
|
screenSize.height / 2 - 20,
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const PopupMenuItem(value: 'all', child: Text("All Roles")),
|
||||||
|
...controller.roles.map((role) {
|
||||||
|
return PopupMenuItem(
|
||||||
|
value: role['id'].toString(),
|
||||||
|
child: Text(role['name'] ?? 'Unknown Role'),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
).then((value) {
|
||||||
|
if (value != null) {
|
||||||
|
controller.onRoleSelected(value == 'all' ? null : value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildEmployeeList() {
|
Widget _buildEmployeeList() {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
if (controller.isLoading.value) {
|
if (controller.isLoading.value) {
|
||||||
@ -255,49 +170,43 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
|
|||||||
return Scrollbar(
|
return Scrollbar(
|
||||||
controller: _employeeListScrollController,
|
controller: _employeeListScrollController,
|
||||||
thumbVisibility: true,
|
thumbVisibility: true,
|
||||||
interactive: true,
|
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: _employeeListScrollController,
|
controller: _employeeListScrollController,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
|
||||||
itemCount: filteredEmployees.length,
|
itemCount: filteredEmployees.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final employee = filteredEmployees[index];
|
final employee = filteredEmployees[index];
|
||||||
final rxBool = controller.uploadingStates[employee.id];
|
final rxBool = controller.uploadingStates[employee.id];
|
||||||
|
|
||||||
return Obx(() => Padding(
|
return Obx(() => Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 0),
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Theme(
|
Checkbox(
|
||||||
data: Theme.of(context)
|
shape: RoundedRectangleBorder(
|
||||||
.copyWith(unselectedWidgetColor: Colors.black),
|
borderRadius: BorderRadius.circular(4),
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
|
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),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(employee.name,
|
child: Text(employee.name,
|
||||||
style: TextStyle(fontSize: 14))),
|
style: const TextStyle(fontSize: 14))),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
@ -307,6 +216,38 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectedEmployees() {
|
||||||
|
return Obx(() {
|
||||||
|
if (controller.selectedEmployees.isEmpty) return Container();
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: controller.selectedEmployees.map((e) {
|
||||||
|
return Obx(() {
|
||||||
|
final isSelected =
|
||||||
|
controller.uploadingStates[e.id]?.value ?? false;
|
||||||
|
if (!isSelected) return Container();
|
||||||
|
|
||||||
|
return Chip(
|
||||||
|
label:
|
||||||
|
Text(e.name, style: const TextStyle(color: Colors.white)),
|
||||||
|
backgroundColor: const Color.fromARGB(255, 95, 132, 255),
|
||||||
|
deleteIcon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
onDeleted: () {
|
||||||
|
controller.uploadingStates[e.id]?.value = false;
|
||||||
|
controller.updateSelectedEmployees();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildTextField({
|
Widget _buildTextField({
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required String label,
|
required String label,
|
||||||
@ -331,13 +272,12 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
keyboardType: keyboardType,
|
keyboardType: keyboardType,
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
decoration: InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: hintText,
|
hintText: '',
|
||||||
border: const OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
validator: (value) => this
|
validator: (value) =>
|
||||||
.controller
|
this.controller.formFieldValidator(value, fieldType: validatorType),
|
||||||
.formFieldValidator(value, fieldType: validatorType),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -4,8 +4,7 @@ import 'package:marco/controller/task_planing/add_task_controller.dart';
|
|||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
|
||||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
|
||||||
|
|
||||||
void showCreateTaskBottomSheet({
|
void showCreateTaskBottomSheet({
|
||||||
required String workArea,
|
required String workArea,
|
||||||
@ -27,197 +26,120 @@ void showCreateTaskBottomSheet({
|
|||||||
Get.bottomSheet(
|
Get.bottomSheet(
|
||||||
StatefulBuilder(
|
StatefulBuilder(
|
||||||
builder: (context, setState) {
|
builder: (context, setState) {
|
||||||
return LayoutBuilder(
|
return BaseBottomSheet(
|
||||||
builder: (context, constraints) {
|
title: "Create Task",
|
||||||
final isLarge = constraints.maxWidth > 600;
|
onCancel: () => Get.back(),
|
||||||
final horizontalPadding =
|
onSubmit: () async {
|
||||||
isLarge ? constraints.maxWidth * 0.2 : 16.0;
|
final plannedValue =
|
||||||
|
int.tryParse(plannedTaskController.text.trim()) ?? 0;
|
||||||
|
final comment = descriptionController.text.trim();
|
||||||
|
final selectedCategoryId = controller.selectedCategoryId.value;
|
||||||
|
|
||||||
return // Inside showManageTaskBottomSheet...
|
if (selectedCategoryId == null) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "error",
|
||||||
|
message: "Please select a work category!",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
SafeArea(
|
final success = await controller.createTask(
|
||||||
child: Material(
|
parentTaskId: parentTaskId,
|
||||||
color: Colors.white,
|
plannedTask: plannedValue,
|
||||||
borderRadius:
|
comment: comment,
|
||||||
const BorderRadius.vertical(top: Radius.circular(20)),
|
workAreaId: workAreaId,
|
||||||
child: Container(
|
activityId: activityId,
|
||||||
constraints: const BoxConstraints(maxHeight: 760),
|
categoryId: selectedCategoryId,
|
||||||
padding: EdgeInsets.fromLTRB(
|
);
|
||||||
horizontalPadding, 12, horizontalPadding, 24),
|
|
||||||
child: SingleChildScrollView(
|
if (success) {
|
||||||
child: Column(
|
Get.back();
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Future.delayed(const Duration(milliseconds: 300), () {
|
||||||
|
onSubmit();
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Success",
|
||||||
|
message: "Task created successfully!",
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
submitText: "Submit",
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_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: [
|
children: [
|
||||||
Center(
|
Text(
|
||||||
child: Container(
|
selectedName,
|
||||||
width: 40,
|
style: const TextStyle(
|
||||||
height: 4,
|
fontSize: 14, color: Colors.black87),
|
||||||
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)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
const Icon(Icons.arrow_drop_down),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
}),
|
||||||
);
|
],
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
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:intl/intl.dart';
|
||||||
|
import 'package:marco/controller/dashboard/daily_task_controller.dart';
|
||||||
|
import 'package:marco/controller/permission_controller.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
|
||||||
class DailyProgressReportFilter extends StatefulWidget {
|
class DailyProgressReportFilter extends StatelessWidget {
|
||||||
final DailyTaskController controller;
|
final DailyTaskController controller;
|
||||||
final PermissionController permissionController;
|
final PermissionController permissionController;
|
||||||
|
|
||||||
@ -14,20 +15,9 @@ class DailyProgressReportFilter extends StatefulWidget {
|
|||||||
required this.permissionController,
|
required this.permissionController,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
|
||||||
State<DailyProgressReportFilter> createState() =>
|
|
||||||
_DailyProgressReportFilterState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DailyProgressReportFilterState extends State<DailyProgressReportFilter> {
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
}
|
|
||||||
|
|
||||||
String getLabelText() {
|
String getLabelText() {
|
||||||
final startDate = widget.controller.startDateTask;
|
final startDate = controller.startDateTask;
|
||||||
final endDate = widget.controller.endDateTask;
|
final endDate = controller.endDateTask;
|
||||||
if (startDate != null && endDate != null) {
|
if (startDate != null && endDate != null) {
|
||||||
final start = DateFormat('dd MM yyyy').format(startDate);
|
final start = DateFormat('dd MM yyyy').format(startDate);
|
||||||
final end = DateFormat('dd MM yyyy').format(endDate);
|
final end = DateFormat('dd MM yyyy').format(endDate);
|
||||||
@ -38,105 +28,55 @@ class _DailyProgressReportFilterState extends State<DailyProgressReportFilter> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return BaseBottomSheet(
|
||||||
child: Padding(
|
title: "Filter Tasks",
|
||||||
padding: EdgeInsets.only(
|
onCancel: () => Navigator.pop(context),
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
|
||||||
),
|
onSubmit: () {
|
||||||
child: SingleChildScrollView(
|
Navigator.pop(context, {
|
||||||
child: Column(
|
'startDate': controller.startDateTask,
|
||||||
mainAxisSize: MainAxisSize.min,
|
'endDate': controller.endDateTask,
|
||||||
children: [
|
});
|
||||||
Padding(
|
},
|
||||||
padding: const EdgeInsets.only(top: 12, bottom: 8),
|
child: Column(
|
||||||
child: Center(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Container(
|
children: [
|
||||||
width: 40,
|
MyText.titleSmall("Select Date Range", fontWeight: 600),
|
||||||
height: 4,
|
const SizedBox(height: 8),
|
||||||
decoration: BoxDecoration(
|
InkWell(
|
||||||
color: Colors.grey[400],
|
borderRadius: BorderRadius.circular(10),
|
||||||
borderRadius: BorderRadius.circular(4),
|
onTap: () => controller.selectDateRangeForTaskData(
|
||||||
),
|
context,
|
||||||
),
|
controller,
|
||||||
),
|
),
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
border: Border.all(color: Colors.grey.shade400),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
const Divider(),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
Padding(
|
child: Row(
|
||||||
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
|
children: [
|
||||||
child: Align(
|
Icon(Icons.date_range, color: Colors.blue.shade600),
|
||||||
alignment: Alignment.centerLeft,
|
const SizedBox(width: 12),
|
||||||
child: MyText.titleSmall(
|
Expanded(
|
||||||
"Select Date Range",
|
child: Text(
|
||||||
fontWeight: 600,
|
getLabelText(),
|
||||||
),
|
style: const TextStyle(
|
||||||
),
|
fontSize: 16,
|
||||||
),
|
color: Colors.black87,
|
||||||
Padding(
|
fontWeight: FontWeight.w500,
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
child: const Text('Apply Filter'),
|
|
||||||
onPressed: () {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
Navigator.pop(context, {
|
|
||||||
'startDate': widget.controller.startDateTask,
|
|
||||||
'endDate': widget.controller.endDateTask,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
const Icon(Icons.arrow_drop_down, color: Colors.grey),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
392
lib/model/dailyTaskPlaning/report_action_widgets.dart
Normal file
392
lib/model/dailyTaskPlaning/report_action_widgets.dart
Normal file
@ -0,0 +1,392 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.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/image_viewer_dialog.dart';
|
||||||
|
import 'package:marco/helpers/widgets/avatar.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
/// Show labeled row with optional icon
|
||||||
|
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! : "-"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show uploaded network images
|
||||||
|
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),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]),
|
||||||
|
MySpacing.width(8),
|
||||||
|
MyText.titleSmall(title, fontWeight: 600),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
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,
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local image picker preview (with file images)
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Comment list widget
|
||||||
|
Widget buildCommentList(
|
||||||
|
List<Map<String, dynamic>> comments, BuildContext context, String Function(String) timeAgo) {
|
||||||
|
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: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
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),
|
||||||
|
MyText.bodyMedium(commentText,
|
||||||
|
fontWeight: 500, color: Colors.black87),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (imageUrls.isNotEmpty) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.attach_file_outlined,
|
||||||
|
size: 18, color: Colors.grey[700]),
|
||||||
|
MySpacing.width(8),
|
||||||
|
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,
|
||||||
|
builder: (_) => ImageViewerDialog(
|
||||||
|
imageSources: imageUrls,
|
||||||
|
initialIndex: imageIndex,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Image.network(
|
||||||
|
imageUrl,
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel + Submit buttons
|
||||||
|
Widget buildCommentActionButtons({
|
||||||
|
required VoidCallback onCancel,
|
||||||
|
required Future<void> Function() onSubmit,
|
||||||
|
required RxBool isLoading,
|
||||||
|
}) {
|
||||||
|
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.send, color: Colors.white, size: 18),
|
||||||
|
label: isLoading.value
|
||||||
|
? const SizedBox()
|
||||||
|
: MyText.bodyMedium("Submit",
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a UTC timestamp to a relative time string
|
||||||
|
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 "${date.day.toString().padLeft(2, '0')}-${date.month.toString().padLeft(2, '0')}-${date.year}";
|
||||||
|
} 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) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,10 +6,12 @@ import 'package:marco/helpers/widgets/my_button.dart';
|
|||||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
|
||||||
class ReportTaskBottomSheet extends StatefulWidget {
|
class ReportTaskBottomSheet extends StatefulWidget {
|
||||||
final Map<String, dynamic> taskData;
|
final Map<String, dynamic> taskData;
|
||||||
final VoidCallback? onReportSuccess;
|
final VoidCallback? onReportSuccess;
|
||||||
|
|
||||||
const ReportTaskBottomSheet({
|
const ReportTaskBottomSheet({
|
||||||
super.key,
|
super.key,
|
||||||
required this.taskData,
|
required this.taskData,
|
||||||
@ -27,464 +29,282 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Initialize the controller with a unique tag (optional)
|
controller = Get.put(
|
||||||
controller = Get.put(ReportTaskController(),
|
ReportTaskController(),
|
||||||
tag: widget.taskData['taskId'] ?? UniqueKey().toString());
|
tag: widget.taskData['taskId'] ?? UniqueKey().toString(),
|
||||||
|
);
|
||||||
|
_preFillFormFields();
|
||||||
|
}
|
||||||
|
|
||||||
final taskData = widget.taskData;
|
void _preFillFormFields() {
|
||||||
controller.basicValidator.getController('assigned_date')?.text =
|
final data = widget.taskData;
|
||||||
taskData['assignedOn'] ?? '';
|
final v = controller.basicValidator;
|
||||||
controller.basicValidator.getController('assigned_by')?.text =
|
|
||||||
taskData['assignedBy'] ?? '';
|
v.getController('assigned_date')?.text = data['assignedOn'] ?? '';
|
||||||
controller.basicValidator.getController('work_area')?.text =
|
v.getController('assigned_by')?.text = data['assignedBy'] ?? '';
|
||||||
taskData['location'] ?? '';
|
v.getController('work_area')?.text = data['location'] ?? '';
|
||||||
controller.basicValidator.getController('activity')?.text =
|
v.getController('activity')?.text = data['activity'] ?? '';
|
||||||
taskData['activity'] ?? '';
|
v.getController('team_size')?.text = data['teamSize']?.toString() ?? '';
|
||||||
controller.basicValidator.getController('team_size')?.text =
|
v.getController('assigned')?.text = data['assigned'] ?? '';
|
||||||
taskData['teamSize']?.toString() ?? '';
|
v.getController('task_id')?.text = data['taskId'] ?? '';
|
||||||
controller.basicValidator.getController('assigned')?.text =
|
v.getController('completed_work')?.clear();
|
||||||
taskData['assigned'] ?? '';
|
v.getController('comment')?.clear();
|
||||||
controller.basicValidator.getController('task_id')?.text =
|
|
||||||
taskData['taskId'] ?? '';
|
|
||||||
controller.basicValidator.getController('completed_work')?.clear();
|
|
||||||
controller.basicValidator.getController('comment')?.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Obx(() {
|
||||||
decoration: BoxDecoration(
|
return BaseBottomSheet(
|
||||||
color: Colors.white,
|
title: "Report Task",
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
isSubmitting: controller.reportStatus.value == ApiStatus.loading,
|
||||||
),
|
onCancel: () => Navigator.of(context).pop(),
|
||||||
child: SingleChildScrollView(
|
onSubmit: _handleSubmit,
|
||||||
padding: EdgeInsets.only(
|
child: Form(
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
key: controller.basicValidator.formKey,
|
||||||
left: 24,
|
child: Column(
|
||||||
right: 24,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
top: 12,
|
children: [
|
||||||
),
|
_buildRow("Assigned Date", controller.basicValidator.getController('assigned_date')?.text),
|
||||||
child: Column(
|
_buildRow("Assigned By", controller.basicValidator.getController('assigned_by')?.text),
|
||||||
mainAxisSize: MainAxisSize.min,
|
_buildRow("Work Area", controller.basicValidator.getController('work_area')?.text),
|
||||||
children: [
|
_buildRow("Activity", controller.basicValidator.getController('activity')?.text),
|
||||||
// Drag handle
|
_buildRow("Team Size", controller.basicValidator.getController('team_size')?.text),
|
||||||
Container(
|
_buildRow(
|
||||||
width: 40,
|
"Assigned",
|
||||||
height: 4,
|
"${controller.basicValidator.getController('assigned')?.text ?? '-'} "
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
"of ${widget.taskData['pendingWork'] ?? '-'} Pending",
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade400,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
),
|
||||||
),
|
_buildCompletedWorkField(),
|
||||||
GetBuilder<ReportTaskController>(
|
_buildCommentField(),
|
||||||
tag: widget.taskData['taskId'] ?? '',
|
Obx(() => _buildImageSection()),
|
||||||
init: controller,
|
],
|
||||||
builder: (_) {
|
|
||||||
return Form(
|
|
||||||
key: controller.basicValidator.formKey,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Center(
|
|
||||||
child: MyText.titleMedium(
|
|
||||||
"Report Task",
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.height(16),
|
|
||||||
buildRow(
|
|
||||||
"Assigned Date",
|
|
||||||
controller.basicValidator
|
|
||||||
.getController('assigned_date')
|
|
||||||
?.text
|
|
||||||
.trim()),
|
|
||||||
buildRow(
|
|
||||||
"Assigned By",
|
|
||||||
controller.basicValidator
|
|
||||||
.getController('assigned_by')
|
|
||||||
?.text
|
|
||||||
.trim()),
|
|
||||||
buildRow(
|
|
||||||
"Work Area",
|
|
||||||
controller.basicValidator
|
|
||||||
.getController('work_area')
|
|
||||||
?.text
|
|
||||||
.trim()),
|
|
||||||
buildRow(
|
|
||||||
"Activity",
|
|
||||||
controller.basicValidator
|
|
||||||
.getController('activity')
|
|
||||||
?.text
|
|
||||||
.trim()),
|
|
||||||
buildRow(
|
|
||||||
"Team Size",
|
|
||||||
controller.basicValidator
|
|
||||||
.getController('team_size')
|
|
||||||
?.text
|
|
||||||
.trim()),
|
|
||||||
buildRow(
|
|
||||||
"Assigned",
|
|
||||||
"${controller.basicValidator.getController('assigned')?.text.trim()} "
|
|
||||||
"of ${widget.taskData['pendingWork'] ?? '-'} Pending"),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.work_outline,
|
|
||||||
size: 18, color: Colors.grey[700]),
|
|
||||||
MySpacing.width(8),
|
|
||||||
MyText.titleSmall(
|
|
||||||
"Completed Work:",
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
MySpacing.height(8),
|
|
||||||
TextFormField(
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.trim().isEmpty) {
|
|
||||||
return 'Please enter completed work';
|
|
||||||
}
|
|
||||||
final completed = int.tryParse(value.trim());
|
|
||||||
final pending = widget.taskData['pendingWork'] ?? 0;
|
|
||||||
|
|
||||||
if (completed == null) {
|
|
||||||
return 'Enter a valid number';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (completed > pending) {
|
|
||||||
return 'Completed work cannot exceed pending work $pending';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
controller: controller.basicValidator
|
|
||||||
.getController('completed_work'),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: "eg: 10",
|
|
||||||
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
|
||||||
border: outlineInputBorder,
|
|
||||||
enabledBorder: outlineInputBorder,
|
|
||||||
focusedBorder: focusedInputBorder,
|
|
||||||
contentPadding: MySpacing.all(16),
|
|
||||||
isCollapsed: true,
|
|
||||||
floatingLabelBehavior: FloatingLabelBehavior.never,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.height(24),
|
|
||||||
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(24),
|
|
||||||
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 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: (_, __) =>
|
|
||||||
MySpacing.width(12),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final file = images[index];
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => Dialog(
|
|
||||||
child: InteractiveViewer(
|
|
||||||
child: Image.file(file),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
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: () => controller
|
|
||||||
.removeImageAt(index),
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black54,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(Icons.close,
|
|
||||||
size: 20,
|
|
||||||
color: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.height(16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: MyButton.outlined(
|
|
||||||
onPressed: () => controller.pickImages(
|
|
||||||
fromCamera: true),
|
|
||||||
padding: MySpacing.xy(12, 10),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment:
|
|
||||||
MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
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: () => controller.pickImages(
|
|
||||||
fromCamera: false),
|
|
||||||
padding: MySpacing.xy(12, 10),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment:
|
|
||||||
MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.upload_file,
|
|
||||||
size: 16,
|
|
||||||
color: Colors.blueAccent),
|
|
||||||
MySpacing.width(6),
|
|
||||||
MyText.bodySmall('Upload',
|
|
||||||
color: Colors.blueAccent),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
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(() {
|
|
||||||
final isLoading =
|
|
||||||
controller.reportStatus.value == ApiStatus.loading;
|
|
||||||
|
|
||||||
return ElevatedButton.icon(
|
|
||||||
onPressed: isLoading
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
if (controller.basicValidator.validateForm()) {
|
|
||||||
final success = await controller.reportTask(
|
|
||||||
projectId: controller.basicValidator
|
|
||||||
.getController('task_id')
|
|
||||||
?.text ??
|
|
||||||
'',
|
|
||||||
comment: controller.basicValidator
|
|
||||||
.getController('comment')
|
|
||||||
?.text ??
|
|
||||||
'',
|
|
||||||
completedTask: int.tryParse(
|
|
||||||
controller.basicValidator
|
|
||||||
.getController('completed_work')
|
|
||||||
?.text ??
|
|
||||||
'') ??
|
|
||||||
0,
|
|
||||||
checklist: [],
|
|
||||||
reportedDate: DateTime.now(),
|
|
||||||
images: controller.selectedImages,
|
|
||||||
);
|
|
||||||
if (success && widget.onReportSuccess != null) {
|
|
||||||
widget.onReportSuccess!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: isLoading
|
|
||||||
? const SizedBox(
|
|
||||||
height: 16,
|
|
||||||
width: 16,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.check_circle_outline,
|
|
||||||
color: Colors.white, size: 18),
|
|
||||||
label: isLoading
|
|
||||||
? const SizedBox.shrink()
|
|
||||||
: MyText.bodyMedium(
|
|
||||||
"Report",
|
|
||||||
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) {
|
Future<void> _handleSubmit() async {
|
||||||
IconData icon;
|
final v = controller.basicValidator;
|
||||||
switch (label) {
|
|
||||||
case "Assigned Date":
|
if (v.validateForm()) {
|
||||||
icon = Icons.calendar_today_outlined;
|
final success = await controller.reportTask(
|
||||||
break;
|
projectId: v.getController('task_id')?.text ?? '',
|
||||||
case "Assigned By":
|
comment: v.getController('comment')?.text ?? '',
|
||||||
icon = Icons.person_outline;
|
completedTask: int.tryParse(v.getController('completed_work')?.text ?? '') ?? 0,
|
||||||
break;
|
checklist: [],
|
||||||
case "Work Area":
|
reportedDate: DateTime.now(),
|
||||||
icon = Icons.place_outlined;
|
images: controller.selectedImages,
|
||||||
break;
|
);
|
||||||
case "Activity":
|
|
||||||
icon = Icons.run_circle_outlined;
|
if (success) {
|
||||||
break;
|
widget.onReportSuccess?.call();
|
||||||
case "Team Size":
|
}
|
||||||
icon = Icons.group_outlined;
|
|
||||||
break;
|
|
||||||
case "Assigned":
|
|
||||||
icon = Icons.assignment_turned_in_outlined;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
icon = Icons.info_outline;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRow(String label, String? value) {
|
||||||
|
final icons = {
|
||||||
|
"Assigned Date": Icons.calendar_today_outlined,
|
||||||
|
"Assigned By": Icons.person_outline,
|
||||||
|
"Work Area": Icons.place_outlined,
|
||||||
|
"Activity": Icons.run_circle_outlined,
|
||||||
|
"Team Size": Icons.group_outlined,
|
||||||
|
"Assigned": Icons.assignment_turned_in_outlined,
|
||||||
|
};
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 18, color: Colors.grey[700]),
|
Icon(icons[label] ?? Icons.info_outline, size: 18, color: Colors.grey[700]),
|
||||||
MySpacing.width(8),
|
MySpacing.width(8),
|
||||||
MyText.titleSmall(
|
MyText.titleSmall("$label:", fontWeight: 600),
|
||||||
"$label:",
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
MySpacing.width(12),
|
MySpacing.width(12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"),
|
child: MyText.bodyMedium(value?.trim().isNotEmpty == true ? value!.trim() : "-"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildCompletedWorkField() {
|
||||||
|
final pending = widget.taskData['pendingWork'] ?? 0;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.work_outline, size: 18, color: Colors.grey[700]),
|
||||||
|
MySpacing.width(8),
|
||||||
|
MyText.titleSmall("Completed Work:", fontWeight: 600),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextFormField(
|
||||||
|
controller: controller.basicValidator.getController('completed_work'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) return 'Please enter completed work';
|
||||||
|
final completed = int.tryParse(value.trim());
|
||||||
|
if (completed == null) return 'Enter a valid number';
|
||||||
|
if (completed > pending) return 'Completed work cannot exceed pending work $pending';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: "eg: 10",
|
||||||
|
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||||
|
border: outlineInputBorder,
|
||||||
|
enabledBorder: outlineInputBorder,
|
||||||
|
focusedBorder: focusedInputBorder,
|
||||||
|
contentPadding: MySpacing.all(16),
|
||||||
|
isCollapsed: true,
|
||||||
|
floatingLabelBehavior: FloatingLabelBehavior.never,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(24),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCommentField() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]),
|
||||||
|
MySpacing.width(8),
|
||||||
|
MyText.titleSmall("Comment:", fontWeight: 600),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextFormField(
|
||||||
|
controller: controller.basicValidator.getController('comment'),
|
||||||
|
validator: controller.basicValidator.getValidation('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(24),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImageSection() {
|
||||||
|
final images = controller.selectedImages;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.camera_alt_outlined, size: 18, color: Colors.grey[700]),
|
||||||
|
MySpacing.width(8),
|
||||||
|
MyText.titleSmall("Attach Photos:", fontWeight: 600),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(12),
|
||||||
|
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: (_, __) => MySpacing.width(12),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final file = images[index];
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => Dialog(
|
||||||
|
child: InteractiveViewer(child: Image.file(file)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
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: () => controller.removeImageAt(index),
|
||||||
|
child: Container(
|
||||||
|
decoration: 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: () => controller.pickImages(fromCamera: true),
|
||||||
|
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: () => controller.pickImages(fromCamera: false),
|
||||||
|
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,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:marco/controller/directory/add_contact_controller.dart';
|
import 'package:marco/controller/directory/add_contact_controller.dart';
|
||||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
@ -8,6 +8,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
|
|||||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||||
import 'package:marco/model/directory/contact_model.dart';
|
import 'package:marco/model/directory/contact_model.dart';
|
||||||
import 'package:marco/helpers/utils/contact_picker_helper.dart';
|
import 'package:marco/helpers/utils/contact_picker_helper.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
|
||||||
class AddContactBottomSheet extends StatefulWidget {
|
class AddContactBottomSheet extends StatefulWidget {
|
||||||
final ContactModel? existingContact;
|
final ContactModel? existingContact;
|
||||||
@ -21,106 +22,98 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
|||||||
final controller = Get.put(AddContactController());
|
final controller = Get.put(AddContactController());
|
||||||
final formKey = GlobalKey<FormState>();
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
final nameController = TextEditingController();
|
final nameCtrl = TextEditingController();
|
||||||
final orgController = TextEditingController();
|
final orgCtrl = TextEditingController();
|
||||||
final addressController = TextEditingController();
|
final addrCtrl = TextEditingController();
|
||||||
final descriptionController = TextEditingController();
|
final descCtrl = TextEditingController();
|
||||||
final tagTextController = TextEditingController();
|
final tagCtrl = TextEditingController();
|
||||||
final RxBool showAdvanced = false.obs;
|
|
||||||
final RxList<TextEditingController> emailControllers =
|
|
||||||
<TextEditingController>[].obs;
|
|
||||||
final RxList<RxString> emailLabels = <RxString>[].obs;
|
|
||||||
|
|
||||||
final RxList<TextEditingController> phoneControllers =
|
final showAdvanced = false.obs;
|
||||||
<TextEditingController>[].obs;
|
final bucketError = ''.obs;
|
||||||
final RxList<RxString> phoneLabels = <RxString>[].obs;
|
|
||||||
|
final emailCtrls = <TextEditingController>[].obs;
|
||||||
|
final emailLabels = <RxString>[].obs;
|
||||||
|
|
||||||
|
final phoneCtrls = <TextEditingController>[].obs;
|
||||||
|
final phoneLabels = <RxString>[].obs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
controller.resetForm();
|
controller.resetForm();
|
||||||
|
_initFields();
|
||||||
|
}
|
||||||
|
|
||||||
nameController.text = widget.existingContact?.name ?? '';
|
void _initFields() {
|
||||||
orgController.text = widget.existingContact?.organization ?? '';
|
final c = widget.existingContact;
|
||||||
addressController.text = widget.existingContact?.address ?? '';
|
if (c != null) {
|
||||||
descriptionController.text = widget.existingContact?.description ?? '';
|
nameCtrl.text = c.name;
|
||||||
tagTextController.clear();
|
orgCtrl.text = c.organization;
|
||||||
|
addrCtrl.text = c.address;
|
||||||
|
descCtrl.text = c.description;
|
||||||
|
|
||||||
if (widget.existingContact != null) {
|
emailCtrls.assignAll(c.contactEmails.isEmpty
|
||||||
emailControllers.clear();
|
? [TextEditingController()]
|
||||||
emailLabels.clear();
|
: c.contactEmails
|
||||||
for (var email in widget.existingContact!.contactEmails) {
|
.map((e) => TextEditingController(text: e.emailAddress)));
|
||||||
emailControllers.add(TextEditingController(text: email.emailAddress));
|
emailLabels.assignAll(c.contactEmails.isEmpty
|
||||||
emailLabels.add((email.label).obs);
|
? ['Office'.obs]
|
||||||
}
|
: c.contactEmails.map((e) => e.label.obs));
|
||||||
if (emailControllers.isEmpty) {
|
|
||||||
emailControllers.add(TextEditingController());
|
|
||||||
emailLabels.add('Office'.obs);
|
|
||||||
}
|
|
||||||
|
|
||||||
phoneControllers.clear();
|
phoneCtrls.assignAll(c.contactPhones.isEmpty
|
||||||
phoneLabels.clear();
|
? [TextEditingController()]
|
||||||
for (var phone in widget.existingContact!.contactPhones) {
|
: c.contactPhones
|
||||||
phoneControllers.add(TextEditingController(text: phone.phoneNumber));
|
.map((p) => TextEditingController(text: p.phoneNumber)));
|
||||||
phoneLabels.add((phone.label).obs);
|
phoneLabels.assignAll(c.contactPhones.isEmpty
|
||||||
}
|
? ['Work'.obs]
|
||||||
if (phoneControllers.isEmpty) {
|
: c.contactPhones.map((p) => p.label.obs));
|
||||||
phoneControllers.add(TextEditingController());
|
|
||||||
phoneLabels.add('Work'.obs);
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.enteredTags.assignAll(
|
controller.enteredTags.assignAll(c.tags.map((e) => e.name));
|
||||||
widget.existingContact!.tags.map((tag) => tag.name).toList(),
|
|
||||||
);
|
|
||||||
|
|
||||||
ever(controller.isInitialized, (bool ready) {
|
ever(controller.isInitialized, (bool ready) {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
final projectIds = widget.existingContact!.projectIds;
|
final projectIds = c.projectIds;
|
||||||
final bucketId = widget.existingContact!.bucketIds.firstOrNull;
|
final bucketId = c.bucketIds.firstOrNull;
|
||||||
final categoryName = widget.existingContact!.contactCategory?.name;
|
final category = c.contactCategory?.name;
|
||||||
|
|
||||||
if (categoryName != null) {
|
if (category != null) controller.selectedCategory.value = category;
|
||||||
controller.selectedCategory.value = categoryName;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (projectIds != null) {
|
if (projectIds != null) {
|
||||||
final names = projectIds
|
controller.selectedProjects.assignAll(
|
||||||
.map((id) {
|
projectIds
|
||||||
return controller.projectsMap.entries
|
.map((id) => controller.projectsMap.entries
|
||||||
.firstWhereOrNull((e) => e.value == id)
|
.firstWhereOrNull((e) => e.value == id)
|
||||||
?.key;
|
?.key)
|
||||||
})
|
.whereType<String>()
|
||||||
.whereType<String>()
|
.toList(),
|
||||||
.toList();
|
);
|
||||||
controller.selectedProjects.assignAll(names);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bucketId != null) {
|
if (bucketId != null) {
|
||||||
final name = controller.bucketsMap.entries
|
final name = controller.bucketsMap.entries
|
||||||
.firstWhereOrNull((e) => e.value == bucketId)
|
.firstWhereOrNull((e) => e.value == bucketId)
|
||||||
?.key;
|
?.key;
|
||||||
if (name != null) {
|
if (name != null) controller.selectedBucket.value = name;
|
||||||
controller.selectedBucket.value = name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
emailControllers.add(TextEditingController());
|
emailCtrls.add(TextEditingController());
|
||||||
emailLabels.add('Office'.obs);
|
emailLabels.add('Office'.obs);
|
||||||
phoneControllers.add(TextEditingController());
|
phoneCtrls.add(TextEditingController());
|
||||||
phoneLabels.add('Work'.obs);
|
phoneLabels.add('Work'.obs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
nameController.dispose();
|
nameCtrl.dispose();
|
||||||
orgController.dispose();
|
orgCtrl.dispose();
|
||||||
tagTextController.dispose();
|
addrCtrl.dispose();
|
||||||
addressController.dispose();
|
descCtrl.dispose();
|
||||||
descriptionController.dispose();
|
tagCtrl.dispose();
|
||||||
emailControllers.forEach((e) => e.dispose());
|
emailCtrls.forEach((c) => c.dispose());
|
||||||
phoneControllers.forEach((p) => p.dispose());
|
phoneCtrls.forEach((c) => c.dispose());
|
||||||
Get.delete<AddContactController>();
|
Get.delete<AddContactController>();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@ -147,158 +140,38 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
|||||||
isDense: true,
|
isDense: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildLabeledRow(
|
Widget _textField(String label, TextEditingController ctrl,
|
||||||
String label,
|
{bool required = false, int maxLines = 1}) {
|
||||||
RxString selectedLabel,
|
return Column(
|
||||||
List<String> options,
|
|
||||||
String inputLabel,
|
|
||||||
TextEditingController controller,
|
|
||||||
TextInputType inputType,
|
|
||||||
{VoidCallback? onRemove}) {
|
|
||||||
return Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
MyText.labelMedium(label),
|
||||||
child: Column(
|
MySpacing.height(8),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
TextFormField(
|
||||||
children: [
|
controller: ctrl,
|
||||||
MyText.labelMedium(label),
|
maxLines: maxLines,
|
||||||
MySpacing.height(8),
|
decoration: _inputDecoration("Enter $label"),
|
||||||
_popupSelector(
|
validator: required
|
||||||
hint: "Label",
|
? (v) =>
|
||||||
selectedValue: selectedLabel,
|
(v == null || v.trim().isEmpty) ? "$label is required" : null
|
||||||
options: options),
|
: null,
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
MySpacing.width(12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MyText.labelMedium(inputLabel),
|
|
||||||
MySpacing.height(8),
|
|
||||||
TextFormField(
|
|
||||||
controller: controller,
|
|
||||||
keyboardType: inputType,
|
|
||||||
maxLength: inputType == TextInputType.phone ? 10 : null,
|
|
||||||
inputFormatters: inputType == TextInputType.phone
|
|
||||||
? [FilteringTextInputFormatter.digitsOnly]
|
|
||||||
: [],
|
|
||||||
decoration: _inputDecoration("Enter $inputLabel").copyWith(
|
|
||||||
counterText: "",
|
|
||||||
suffixIcon: inputType == TextInputType.phone
|
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(Icons.contact_phone,
|
|
||||||
color: Colors.blue),
|
|
||||||
onPressed: () async {
|
|
||||||
final selectedPhone =
|
|
||||||
await ContactPickerHelper.pickIndianPhoneNumber(
|
|
||||||
context);
|
|
||||||
if (selectedPhone != null) {
|
|
||||||
controller.text = selectedPhone;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.trim().isEmpty)
|
|
||||||
return "$inputLabel is required";
|
|
||||||
final trimmed = value.trim();
|
|
||||||
if (inputType == TextInputType.phone) {
|
|
||||||
if (!RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) {
|
|
||||||
return "Enter valid phone number";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (inputType == TextInputType.emailAddress &&
|
|
||||||
!RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$')
|
|
||||||
.hasMatch(trimmed)) {
|
|
||||||
return "Enter valid email";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (onRemove != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 24),
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(Icons.remove_circle_outline, color: Colors.red),
|
|
||||||
onPressed: onRemove,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmailList() => Column(
|
Widget _popupSelector(RxString selected, List<String> options, String hint) =>
|
||||||
children: List.generate(emailControllers.length, (index) {
|
Obx(() {
|
||||||
return Padding(
|
return GestureDetector(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: _buildLabeledRow(
|
|
||||||
"Email Label",
|
|
||||||
emailLabels[index],
|
|
||||||
["Office", "Personal", "Other"],
|
|
||||||
"Email",
|
|
||||||
emailControllers[index],
|
|
||||||
TextInputType.emailAddress,
|
|
||||||
onRemove: emailControllers.length > 1
|
|
||||||
? () {
|
|
||||||
emailControllers.removeAt(index);
|
|
||||||
emailLabels.removeAt(index);
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _buildPhoneList() => Column(
|
|
||||||
children: List.generate(phoneControllers.length, (index) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: _buildLabeledRow(
|
|
||||||
"Phone Label",
|
|
||||||
phoneLabels[index],
|
|
||||||
["Work", "Mobile", "Other"],
|
|
||||||
"Phone",
|
|
||||||
phoneControllers[index],
|
|
||||||
TextInputType.phone,
|
|
||||||
onRemove: phoneControllers.length > 1
|
|
||||||
? () {
|
|
||||||
phoneControllers.removeAt(index);
|
|
||||||
phoneLabels.removeAt(index);
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _popupSelector({
|
|
||||||
required String hint,
|
|
||||||
required RxString selectedValue,
|
|
||||||
required List<String> options,
|
|
||||||
}) {
|
|
||||||
return Obx(() => GestureDetector(
|
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final selected = await showMenu<String>(
|
final selectedItem = await showMenu<String>(
|
||||||
context: context,
|
context: context,
|
||||||
position: RelativeRect.fromLTRB(100, 300, 100, 0),
|
position: RelativeRect.fromLTRB(100, 300, 100, 0),
|
||||||
items: options.map((option) {
|
items: options
|
||||||
return PopupMenuItem<String>(
|
.map((e) => PopupMenuItem<String>(value: e, child: Text(e)))
|
||||||
value: option,
|
.toList(),
|
||||||
child: Text(option),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
);
|
);
|
||||||
|
if (selectedItem != null) selected.value = selectedItem;
|
||||||
if (selected != null) {
|
|
||||||
selectedValue.value = selected;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 48,
|
height: 48,
|
||||||
@ -312,45 +185,126 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(selected.value.isNotEmpty ? selected.value : hint,
|
||||||
selectedValue.value.isNotEmpty ? selectedValue.value : hint,
|
style: const TextStyle(fontSize: 14)),
|
||||||
style: const TextStyle(fontSize: 14),
|
|
||||||
),
|
|
||||||
const Icon(Icons.expand_more, size: 20),
|
const Icon(Icons.expand_more, size: 20),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
));
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Widget _dynamicList(
|
||||||
|
RxList<TextEditingController> ctrls,
|
||||||
|
RxList<RxString> labels,
|
||||||
|
String labelType,
|
||||||
|
List<String> labelOptions,
|
||||||
|
TextInputType type) {
|
||||||
|
return Obx(() {
|
||||||
|
return Column(
|
||||||
|
children: List.generate(ctrls.length, (i) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.labelMedium("$labelType Label"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
_popupSelector(labels[i], labelOptions, "Label"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.width(12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.labelMedium(labelType),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextFormField(
|
||||||
|
controller: ctrls[i],
|
||||||
|
keyboardType: type,
|
||||||
|
maxLength: type == TextInputType.phone ? 10 : null,
|
||||||
|
inputFormatters: type == TextInputType.phone
|
||||||
|
? [FilteringTextInputFormatter.digitsOnly]
|
||||||
|
: [],
|
||||||
|
decoration:
|
||||||
|
_inputDecoration("Enter $labelType").copyWith(
|
||||||
|
counterText: "",
|
||||||
|
suffixIcon: type == TextInputType.phone
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.contact_phone,
|
||||||
|
color: Colors.blue),
|
||||||
|
onPressed: () async {
|
||||||
|
final phone = await ContactPickerHelper
|
||||||
|
.pickIndianPhoneNumber(context);
|
||||||
|
if (phone != null) ctrls[i].text = phone;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty)
|
||||||
|
return null;
|
||||||
|
final trimmed = value.trim();
|
||||||
|
if (type == TextInputType.phone &&
|
||||||
|
!RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) {
|
||||||
|
return "Enter valid phone number";
|
||||||
|
}
|
||||||
|
if (type == TextInputType.emailAddress &&
|
||||||
|
!RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$')
|
||||||
|
.hasMatch(trimmed)) {
|
||||||
|
return "Enter valid email";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (ctrls.length > 1)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 24),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline,
|
||||||
|
color: Colors.red),
|
||||||
|
onPressed: () {
|
||||||
|
ctrls.removeAt(i);
|
||||||
|
labels.removeAt(i);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _sectionLabel(String title) => Column(
|
Widget _tagInput() {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MyText.labelLarge(title, fontWeight: 600),
|
|
||||||
MySpacing.height(4),
|
|
||||||
Divider(thickness: 1, color: Colors.grey.shade200),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _tagInputSection() {
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 48,
|
height: 48,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: tagTextController,
|
controller: tagCtrl,
|
||||||
onChanged: controller.filterSuggestions,
|
onChanged: controller.filterSuggestions,
|
||||||
onSubmitted: (value) {
|
onSubmitted: (v) {
|
||||||
controller.addEnteredTag(value);
|
controller.addEnteredTag(v);
|
||||||
tagTextController.clear();
|
tagCtrl.clear();
|
||||||
controller.clearSuggestions();
|
controller.clearSuggestions();
|
||||||
},
|
},
|
||||||
decoration: _inputDecoration("Start typing to add tags"),
|
decoration: _inputDecoration("Start typing to add tags"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Obx(() => controller.filteredSuggestions.isEmpty
|
Obx(() => controller.filteredSuggestions.isEmpty
|
||||||
? const SizedBox()
|
? const SizedBox.shrink()
|
||||||
: Container(
|
: Container(
|
||||||
margin: const EdgeInsets.only(top: 4),
|
margin: const EdgeInsets.only(top: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -364,14 +318,14 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
|||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
itemCount: controller.filteredSuggestions.length,
|
itemCount: controller.filteredSuggestions.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (_, i) {
|
||||||
final suggestion = controller.filteredSuggestions[index];
|
final suggestion = controller.filteredSuggestions[i];
|
||||||
return ListTile(
|
return ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
title: Text(suggestion),
|
title: Text(suggestion),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
controller.addEnteredTag(suggestion);
|
controller.addEnteredTag(suggestion);
|
||||||
tagTextController.clear();
|
tagCtrl.clear();
|
||||||
controller.clearSuggestions();
|
controller.clearSuggestions();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -392,125 +346,46 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTextField(String label, TextEditingController controller,
|
void _handleSubmit() {
|
||||||
{int maxLines = 1}) {
|
bool valid = formKey.currentState?.validate() ?? false;
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MyText.labelMedium(label),
|
|
||||||
MySpacing.height(8),
|
|
||||||
TextFormField(
|
|
||||||
controller: controller,
|
|
||||||
maxLines: maxLines,
|
|
||||||
decoration: _inputDecoration("Enter $label"),
|
|
||||||
validator: (value) => value == null || value.trim().isEmpty
|
|
||||||
? "$label is required"
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildOrganizationField() {
|
if (controller.selectedBucket.value.isEmpty) {
|
||||||
return Column(
|
bucketError.value = "Bucket is required";
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
valid = false;
|
||||||
children: [
|
} else {
|
||||||
MyText.labelMedium("Organization"),
|
bucketError.value = "";
|
||||||
MySpacing.height(8),
|
}
|
||||||
TextField(
|
|
||||||
controller: orgController,
|
|
||||||
onChanged: controller.filterOrganizationSuggestions,
|
|
||||||
decoration: _inputDecoration("Enter organization"),
|
|
||||||
),
|
|
||||||
Obx(() => controller.filteredOrgSuggestions.isEmpty
|
|
||||||
? const SizedBox()
|
|
||||||
: ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: controller.filteredOrgSuggestions.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final suggestion = controller.filteredOrgSuggestions[index];
|
|
||||||
return ListTile(
|
|
||||||
dense: true,
|
|
||||||
title: Text(suggestion),
|
|
||||||
onTap: () {
|
|
||||||
orgController.text = suggestion;
|
|
||||||
controller.filteredOrgSuggestions.clear();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildActionButtons() {
|
if (!valid) return;
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Get.back();
|
|
||||||
Get.delete<AddContactController>();
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.close, color: Colors.red),
|
|
||||||
label:
|
|
||||||
MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
side: const BorderSide(color: Colors.red),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10)),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.width(12),
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
if (formKey.currentState!.validate()) {
|
|
||||||
final emails = emailControllers
|
|
||||||
.asMap()
|
|
||||||
.entries
|
|
||||||
.where((entry) => entry.value.text.trim().isNotEmpty)
|
|
||||||
.map((entry) => {
|
|
||||||
"label": emailLabels[entry.key].value,
|
|
||||||
"emailAddress": entry.value.text.trim(),
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final phones = phoneControllers
|
final emails = emailCtrls
|
||||||
.asMap()
|
.asMap()
|
||||||
.entries
|
.entries
|
||||||
.where((entry) => entry.value.text.trim().isNotEmpty)
|
.where((e) => e.value.text.trim().isNotEmpty)
|
||||||
.map((entry) => {
|
.map((e) => {
|
||||||
"label": phoneLabels[entry.key].value,
|
"label": emailLabels[e.key].value,
|
||||||
"phoneNumber": entry.value.text.trim(),
|
"emailAddress": e.value.text.trim()
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
controller.submitContact(
|
final phones = phoneCtrls
|
||||||
id: widget.existingContact?.id,
|
.asMap()
|
||||||
name: nameController.text.trim(),
|
.entries
|
||||||
organization: orgController.text.trim(),
|
.where((e) => e.value.text.trim().isNotEmpty)
|
||||||
emails: emails,
|
.map((e) => {
|
||||||
phones: phones,
|
"label": phoneLabels[e.key].value,
|
||||||
address: addressController.text.trim(),
|
"phoneNumber": e.value.text.trim()
|
||||||
description: descriptionController.text.trim(),
|
})
|
||||||
);
|
.toList();
|
||||||
}
|
|
||||||
},
|
controller.submitContact(
|
||||||
icon: const Icon(Icons.check_circle_outline, color: Colors.white),
|
id: widget.existingContact?.id,
|
||||||
label:
|
name: nameCtrl.text.trim(),
|
||||||
MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600),
|
organization: orgCtrl.text.trim(),
|
||||||
style: ElevatedButton.styleFrom(
|
emails: emails,
|
||||||
backgroundColor: Colors.indigo,
|
phones: phones,
|
||||||
shape: RoundedRectangleBorder(
|
address: addrCtrl.text.trim(),
|
||||||
borderRadius: BorderRadius.circular(10)),
|
description: descCtrl.text.trim(),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -521,213 +396,107 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
|||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
return SafeArea(
|
return BaseBottomSheet(
|
||||||
child: SingleChildScrollView(
|
title: widget.existingContact != null
|
||||||
padding: EdgeInsets.only(
|
? "Edit Contact"
|
||||||
top: 32,
|
: "Create New Contact",
|
||||||
).add(MediaQuery.of(context).viewInsets),
|
onCancel: () => Get.back(),
|
||||||
child: Container(
|
onSubmit: _handleSubmit,
|
||||||
decoration: BoxDecoration(
|
isSubmitting: controller.isSubmitting.value,
|
||||||
color: Theme.of(context).cardColor,
|
child: Form(
|
||||||
borderRadius:
|
key: formKey,
|
||||||
const BorderRadius.vertical(top: Radius.circular(24)),
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Padding(
|
children: [
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
|
_textField("Name", nameCtrl, required: true),
|
||||||
child: Form(
|
MySpacing.height(16),
|
||||||
key: formKey,
|
_textField("Organization", orgCtrl, required: true),
|
||||||
child: Column(
|
MySpacing.height(16),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
MyText.labelMedium("Select Bucket"),
|
||||||
children: [
|
MySpacing.height(8),
|
||||||
Center(
|
Stack(
|
||||||
child: MyText.titleMedium(
|
children: [
|
||||||
widget.existingContact != null
|
_popupSelector(controller.selectedBucket, controller.buckets,
|
||||||
? "Edit Contact"
|
"Select Bucket"),
|
||||||
: "Create New Contact",
|
Positioned(
|
||||||
fontWeight: 700,
|
left: 0,
|
||||||
),
|
right: 0,
|
||||||
),
|
top: 56,
|
||||||
MySpacing.height(24),
|
child: Obx(() => bucketError.value.isEmpty
|
||||||
_sectionLabel("Required Fields"),
|
? const SizedBox.shrink()
|
||||||
MySpacing.height(12),
|
: Padding(
|
||||||
_buildTextField("Name", nameController),
|
padding:
|
||||||
MySpacing.height(16),
|
const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
_buildOrganizationField(),
|
child: Text(bucketError.value,
|
||||||
MySpacing.height(16),
|
style: const TextStyle(
|
||||||
MyText.labelMedium("Select Bucket"),
|
color: Colors.red, fontSize: 12)),
|
||||||
MySpacing.height(8),
|
)),
|
||||||
_popupSelector(
|
),
|
||||||
hint: "Select Bucket",
|
],
|
||||||
selectedValue: controller.selectedBucket,
|
|
||||||
options: controller.buckets,
|
|
||||||
),
|
|
||||||
MySpacing.height(24),
|
|
||||||
Obx(() => GestureDetector(
|
|
||||||
onTap: () => showAdvanced.toggle(),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
MyText.labelLarge("Advanced Details (Optional)",
|
|
||||||
fontWeight: 600),
|
|
||||||
Icon(showAdvanced.value
|
|
||||||
? Icons.expand_less
|
|
||||||
: Icons.expand_more),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
Obx(() => showAdvanced.value
|
|
||||||
? Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MySpacing.height(24),
|
|
||||||
_sectionLabel("Contact Info"),
|
|
||||||
MySpacing.height(16),
|
|
||||||
_buildEmailList(),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
emailControllers.add(TextEditingController());
|
|
||||||
emailLabels.add('Office'.obs);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text("Add Email"),
|
|
||||||
),
|
|
||||||
_buildPhoneList(),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
phoneControllers.add(TextEditingController());
|
|
||||||
phoneLabels.add('Work'.obs);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text("Add Phone"),
|
|
||||||
),
|
|
||||||
MySpacing.height(24),
|
|
||||||
_sectionLabel("Other Details"),
|
|
||||||
MySpacing.height(16),
|
|
||||||
MyText.labelMedium("Category"),
|
|
||||||
MySpacing.height(8),
|
|
||||||
_popupSelector(
|
|
||||||
hint: "Select Category",
|
|
||||||
selectedValue: controller.selectedCategory,
|
|
||||||
options: controller.categories,
|
|
||||||
),
|
|
||||||
MySpacing.height(16),
|
|
||||||
MyText.labelMedium("Select Projects"),
|
|
||||||
MySpacing.height(8),
|
|
||||||
_projectSelectorUI(),
|
|
||||||
MySpacing.height(16),
|
|
||||||
MyText.labelMedium("Tags"),
|
|
||||||
MySpacing.height(8),
|
|
||||||
_tagInputSection(),
|
|
||||||
MySpacing.height(16),
|
|
||||||
_buildTextField("Address", addressController,
|
|
||||||
maxLines: 2),
|
|
||||||
MySpacing.height(16),
|
|
||||||
_buildTextField(
|
|
||||||
"Description", descriptionController,
|
|
||||||
maxLines: 2),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: const SizedBox()),
|
|
||||||
MySpacing.height(24),
|
|
||||||
_buildActionButtons(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
MySpacing.height(24),
|
||||||
|
Obx(() => GestureDetector(
|
||||||
|
onTap: () => showAdvanced.toggle(),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
MyText.labelLarge("Advanced Details (Optional)",
|
||||||
|
fontWeight: 600),
|
||||||
|
Icon(showAdvanced.value
|
||||||
|
? Icons.expand_less
|
||||||
|
: Icons.expand_more),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
Obx(() => showAdvanced.value
|
||||||
|
? Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MySpacing.height(24),
|
||||||
|
_dynamicList(
|
||||||
|
emailCtrls,
|
||||||
|
emailLabels,
|
||||||
|
"Email",
|
||||||
|
["Office", "Personal", "Other"],
|
||||||
|
TextInputType.emailAddress),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
emailCtrls.add(TextEditingController());
|
||||||
|
emailLabels.add("Office".obs);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text("Add Email"),
|
||||||
|
),
|
||||||
|
_dynamicList(phoneCtrls, phoneLabels, "Phone",
|
||||||
|
["Work", "Mobile", "Other"], TextInputType.phone),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
phoneCtrls.add(TextEditingController());
|
||||||
|
phoneLabels.add("Work".obs);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text("Add Phone"),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
MyText.labelMedium("Category"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
_popupSelector(controller.selectedCategory,
|
||||||
|
controller.categories, "Select Category"),
|
||||||
|
MySpacing.height(16),
|
||||||
|
MyText.labelMedium("Tags"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
_tagInput(),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_textField("Address", addrCtrl),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_textField("Description", descCtrl),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink()),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _projectSelectorUI() {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () async {
|
|
||||||
await showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext context) {
|
|
||||||
return AlertDialog(
|
|
||||||
title: const Text('Select Projects'),
|
|
||||||
content: Obx(() {
|
|
||||||
return SizedBox(
|
|
||||||
width: double.maxFinite,
|
|
||||||
child: ListView(
|
|
||||||
shrinkWrap: true,
|
|
||||||
children: controller.globalProjects.map((project) {
|
|
||||||
final isSelected =
|
|
||||||
controller.selectedProjects.contains(project);
|
|
||||||
return Theme(
|
|
||||||
data: Theme.of(context).copyWith(
|
|
||||||
unselectedWidgetColor: Colors.black,
|
|
||||||
checkboxTheme: CheckboxThemeData(
|
|
||||||
fillColor: MaterialStateProperty.resolveWith<Color>(
|
|
||||||
(states) {
|
|
||||||
if (states.contains(MaterialState.selected)) {
|
|
||||||
return Colors.white;
|
|
||||||
}
|
|
||||||
return Colors.transparent;
|
|
||||||
}),
|
|
||||||
checkColor: MaterialStateProperty.all(Colors.black),
|
|
||||||
side:
|
|
||||||
const BorderSide(color: Colors.black, width: 2),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: CheckboxListTile(
|
|
||||||
dense: true,
|
|
||||||
title: Text(project),
|
|
||||||
value: isSelected,
|
|
||||||
onChanged: (bool? selected) {
|
|
||||||
if (selected == true) {
|
|
||||||
controller.selectedProjects.add(project);
|
|
||||||
} else {
|
|
||||||
controller.selectedProjects.remove(project);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Done'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
height: 48,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade100,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Obx(() {
|
|
||||||
final selected = controller.selectedProjects;
|
|
||||||
return Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
selected.isEmpty ? "Select Projects" : selected.join(', '),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(fontSize: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(Icons.expand_more, size: 20),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:marco/controller/directory/create_bucket_controller.dart';
|
import 'package:marco/controller/directory/create_bucket_controller.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.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/widgets/my_spacing.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||||
@ -38,125 +39,55 @@ class _CreateBucketBottomSheetState extends State<CreateBucketBottomSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _formContent() {
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.labelMedium("Bucket Name"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextFormField(
|
||||||
|
initialValue: _controller.name.value,
|
||||||
|
onChanged: _controller.updateName,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return "Bucket name is required";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
decoration: _inputDecoration("e.g., Project Docs"),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
MyText.labelMedium("Description"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextFormField(
|
||||||
|
initialValue: _controller.description.value,
|
||||||
|
onChanged: _controller.updateDescription,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: _inputDecoration("Optional bucket description"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return GetBuilder<BucketController>(
|
return GetBuilder<BucketController>(
|
||||||
builder: (_) {
|
builder: (_) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: SingleChildScrollView(
|
child: BaseBottomSheet(
|
||||||
padding: MediaQuery.of(context).viewInsets,
|
title: "Create New Bucket",
|
||||||
child: Container(
|
child: _formContent(),
|
||||||
decoration: BoxDecoration(
|
onCancel: () => Navigator.pop(context, false),
|
||||||
color: theme.cardColor,
|
onSubmit: () async {
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
if (_formKey.currentState!.validate()) {
|
||||||
boxShadow: const [
|
await _controller.createBucket();
|
||||||
BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, -2)),
|
}
|
||||||
],
|
},
|
||||||
),
|
isSubmitting: _controller.isCreating.value,
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 5,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade300,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.height(12),
|
|
||||||
Text("Create New Bucket", style: MyTextStyle.titleLarge(fontWeight: 700)),
|
|
||||||
MySpacing.height(24),
|
|
||||||
Form(
|
|
||||||
key: _formKey,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MyText.labelMedium("Bucket Name"),
|
|
||||||
MySpacing.height(8),
|
|
||||||
TextFormField(
|
|
||||||
initialValue: _controller.name.value,
|
|
||||||
onChanged: _controller.updateName,
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.trim().isEmpty) {
|
|
||||||
return "Bucket name is required";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
decoration: _inputDecoration("e.g., Project Docs"),
|
|
||||||
),
|
|
||||||
MySpacing.height(16),
|
|
||||||
MyText.labelMedium("Description"),
|
|
||||||
MySpacing.height(8),
|
|
||||||
TextFormField(
|
|
||||||
initialValue: _controller.description.value,
|
|
||||||
onChanged: _controller.updateDescription,
|
|
||||||
maxLines: 3,
|
|
||||||
decoration: _inputDecoration("Optional bucket description"),
|
|
||||||
),
|
|
||||||
MySpacing.height(24),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: () => Navigator.pop(context, false),
|
|
||||||
icon: const Icon(Icons.close, color: Colors.red),
|
|
||||||
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: 20, vertical: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.width(12),
|
|
||||||
Expanded(
|
|
||||||
child: Obx(() {
|
|
||||||
return ElevatedButton.icon(
|
|
||||||
onPressed: _controller.isCreating.value
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
if (_formKey.currentState!.validate()) {
|
|
||||||
await _controller.createBucket();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: _controller.isCreating.value
|
|
||||||
? const SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.check_circle_outline, color: Colors.white),
|
|
||||||
label: MyText.bodyMedium(
|
|
||||||
_controller.isCreating.value ? "Creating..." : "Create",
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.indigo,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,170 +1,275 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:marco/controller/directory/directory_controller.dart';
|
import 'package:marco/controller/directory/directory_controller.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
|
||||||
class DirectoryFilterBottomSheet extends StatelessWidget {
|
class DirectoryFilterBottomSheet extends StatefulWidget {
|
||||||
const DirectoryFilterBottomSheet({super.key});
|
const DirectoryFilterBottomSheet({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<DirectoryFilterBottomSheet> createState() =>
|
||||||
final controller = Get.find<DirectoryController>();
|
_DirectoryFilterBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
return Container(
|
class _DirectoryFilterBottomSheetState
|
||||||
decoration: const BoxDecoration(
|
extends State<DirectoryFilterBottomSheet> {
|
||||||
color: Colors.white,
|
final DirectoryController controller = Get.find<DirectoryController>();
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
final _categorySearchQuery = ''.obs;
|
||||||
),
|
final _bucketSearchQuery = ''.obs;
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
final _categoryExpanded = false.obs;
|
||||||
top: 12,
|
final _bucketExpanded = false.obs;
|
||||||
left: 16,
|
|
||||||
right: 16,
|
late final RxList<String> _tempSelectedCategories;
|
||||||
),
|
late final RxList<String> _tempSelectedBuckets;
|
||||||
child: Obx(() {
|
|
||||||
return SingleChildScrollView(
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tempSelectedCategories = controller.selectedCategories.toList().obs;
|
||||||
|
_tempSelectedBuckets = controller.selectedBuckets.toList().obs;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleCategory(String id) {
|
||||||
|
_tempSelectedCategories.contains(id)
|
||||||
|
? _tempSelectedCategories.remove(id)
|
||||||
|
: _tempSelectedCategories.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleBucket(String id) {
|
||||||
|
_tempSelectedBuckets.contains(id)
|
||||||
|
? _tempSelectedBuckets.remove(id)
|
||||||
|
: _tempSelectedBuckets.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetFilters() {
|
||||||
|
_tempSelectedCategories.clear();
|
||||||
|
_tempSelectedBuckets.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BaseBottomSheet(
|
||||||
|
title: "Filter Contacts",
|
||||||
|
onSubmit: () {
|
||||||
|
controller.selectedCategories.value = _tempSelectedCategories;
|
||||||
|
controller.selectedBuckets.value = _tempSelectedBuckets;
|
||||||
|
controller.applyFilters();
|
||||||
|
Get.back();
|
||||||
|
},
|
||||||
|
onCancel: Get.back,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
/// Drag handle
|
Obx(() {
|
||||||
Center(
|
final hasSelections = _tempSelectedCategories.isNotEmpty ||
|
||||||
child: Container(
|
_tempSelectedBuckets.isNotEmpty;
|
||||||
height: 5,
|
if (!hasSelections) return const SizedBox.shrink();
|
||||||
width: 50,
|
return Column(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade300,
|
|
||||||
borderRadius: BorderRadius.circular(2.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
/// Title
|
|
||||||
Center(
|
|
||||||
child: MyText.titleMedium(
|
|
||||||
"Filter Contacts",
|
|
||||||
fontWeight: 700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
/// Categories
|
|
||||||
if (controller.contactCategories.isNotEmpty) ...[
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
MyText.bodyMedium("Categories", fontWeight: 600),
|
MyText("Selected Filters:", fontWeight: 600),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_buildChips(_tempSelectedCategories,
|
||||||
|
controller.contactCategories, _toggleCategory),
|
||||||
|
_buildChips(_tempSelectedBuckets, controller.contactBuckets,
|
||||||
|
_toggleBucket),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
const SizedBox(height: 10),
|
}),
|
||||||
Wrap(
|
|
||||||
spacing: 2,
|
|
||||||
runSpacing: 0,
|
|
||||||
children: controller.contactCategories.map((category) {
|
|
||||||
final selected =
|
|
||||||
controller.selectedCategories.contains(category.id);
|
|
||||||
return AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
child: FilterChip(
|
|
||||||
label: MyText.bodySmall(
|
|
||||||
category.name,
|
|
||||||
color: selected ? Colors.white : Colors.black87,
|
|
||||||
),
|
|
||||||
selected: selected,
|
|
||||||
onSelected: (_) =>
|
|
||||||
controller.toggleCategory(category.id),
|
|
||||||
selectedColor: Colors.indigo,
|
|
||||||
backgroundColor: Colors.grey.shade200,
|
|
||||||
checkmarkColor: Colors.white,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
],
|
|
||||||
|
|
||||||
/// Buckets
|
|
||||||
if (controller.contactBuckets.isNotEmpty) ...[
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
MyText.bodyMedium("Buckets", fontWeight: 600),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Wrap(
|
|
||||||
spacing: 2,
|
|
||||||
runSpacing: 0,
|
|
||||||
children: controller.contactBuckets.map((bucket) {
|
|
||||||
final selected =
|
|
||||||
controller.selectedBuckets.contains(bucket.id);
|
|
||||||
return AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
child: FilterChip(
|
|
||||||
label: MyText.bodySmall(
|
|
||||||
bucket.name,
|
|
||||||
color: selected ? Colors.white : Colors.black87,
|
|
||||||
),
|
|
||||||
selected: selected,
|
|
||||||
onSelected: (_) => controller.toggleBucket(bucket.id),
|
|
||||||
selectedColor: Colors.teal,
|
|
||||||
backgroundColor: Colors.grey.shade200,
|
|
||||||
checkmarkColor: Colors.white,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
/// Action Buttons
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
OutlinedButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () {
|
onPressed: _resetFilters,
|
||||||
controller.selectedCategories.clear();
|
icon: const Icon(Icons.restart_alt, size: 18),
|
||||||
controller.selectedBuckets.clear();
|
label: MyText("Reset All", color: Colors.red),
|
||||||
controller.searchQuery.value = '';
|
style: TextButton.styleFrom(
|
||||||
controller.applyFilters();
|
foregroundColor: Colors.red.shade400,
|
||||||
Get.back();
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.refresh, color: Colors.red),
|
|
||||||
label: MyText.bodyMedium("Clear", color: Colors.red),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
side: const BorderSide(color: Colors.red),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10, vertical: 7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
controller.applyFilters();
|
|
||||||
Get.back();
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.check_circle_outline),
|
|
||||||
label: MyText.bodyMedium("Apply", color: Colors.white),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.indigo,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10, vertical: 7),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
if (controller.contactCategories.isNotEmpty)
|
||||||
|
Obx(() => _buildExpandableFilterSection(
|
||||||
|
title: "Categories",
|
||||||
|
expanded: _categoryExpanded,
|
||||||
|
searchQuery: _categorySearchQuery,
|
||||||
|
allItems: controller.contactCategories,
|
||||||
|
selectedItems: _tempSelectedCategories,
|
||||||
|
onToggle: _toggleCategory,
|
||||||
|
)),
|
||||||
|
if (controller.contactBuckets.isNotEmpty)
|
||||||
|
Obx(() => _buildExpandableFilterSection(
|
||||||
|
title: "Buckets",
|
||||||
|
expanded: _bucketExpanded,
|
||||||
|
searchQuery: _bucketSearchQuery,
|
||||||
|
allItems: controller.contactBuckets,
|
||||||
|
selectedItems: _tempSelectedBuckets,
|
||||||
|
onToggle: _toggleBucket,
|
||||||
|
)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChips(RxList<String> selectedIds, List<dynamic> allItems,
|
||||||
|
Function(String) onRemoved) {
|
||||||
|
final idToName = {for (var item in allItems) item.id: item.name};
|
||||||
|
return Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: selectedIds
|
||||||
|
.map((id) => Chip(
|
||||||
|
label: MyText(idToName[id] ?? "", color: Colors.black87),
|
||||||
|
deleteIcon: const Icon(Icons.close, size: 16),
|
||||||
|
onDeleted: () => onRemoved(id),
|
||||||
|
backgroundColor: Colors.blue.shade50,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildExpandableFilterSection({
|
||||||
|
required String title,
|
||||||
|
required RxBool expanded,
|
||||||
|
required RxString searchQuery,
|
||||||
|
required List<dynamic> allItems,
|
||||||
|
required RxList<String> selectedItems,
|
||||||
|
required Function(String) onToggle,
|
||||||
|
}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => expanded.toggle(),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
expanded.value
|
||||||
|
? Icons.keyboard_arrow_down
|
||||||
|
: Icons.keyboard_arrow_right,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
MyText(
|
||||||
|
"$title",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (expanded.value)
|
||||||
|
_buildFilterSection(
|
||||||
|
searchQuery: searchQuery,
|
||||||
|
allItems: allItems,
|
||||||
|
selectedItems: selectedItems,
|
||||||
|
onToggle: onToggle,
|
||||||
|
title: title,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFilterSection({
|
||||||
|
required String title,
|
||||||
|
required RxString searchQuery,
|
||||||
|
required List<dynamic> allItems,
|
||||||
|
required RxList<String> selectedItems,
|
||||||
|
required Function(String) onToggle,
|
||||||
|
}) {
|
||||||
|
final filteredList = allItems.where((item) {
|
||||||
|
if (searchQuery.isEmpty) return true;
|
||||||
|
return item.name.toLowerCase().contains(searchQuery.value.toLowerCase());
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
TextField(
|
||||||
|
onChanged: (value) => searchQuery.value = value,
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||||
|
hintText: "Search $title...",
|
||||||
|
hintStyle: const TextStyle(fontSize: 13),
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 18),
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (filteredList.isEmpty)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.sentiment_dissatisfied, color: Colors.grey),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
MyText("No results found.",
|
||||||
|
color: Colors.grey.shade600, fontSize: 14),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 230),
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: filteredList.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = filteredList[index];
|
||||||
|
final isSelected = selectedItems.contains(item.id);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => onToggle(item.id),
|
||||||
|
child: Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
isSelected ? Colors.blueAccent : Colors.white,
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.black,
|
||||||
|
width: 1.2,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: isSelected
|
||||||
|
? const Icon(Icons.check,
|
||||||
|
size: 14, color: Colors.white)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
MyText(item.name, fontSize: 14),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,22 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:marco/controller/directory/manage_bucket_controller.dart';
|
import 'package:marco/controller/directory/manage_bucket_controller.dart';
|
||||||
|
import 'package:marco/controller/directory/directory_controller.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.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/widgets/my_spacing.dart';
|
||||||
import 'package:marco/model/directory/contact_bucket_list_model.dart';
|
|
||||||
import 'package:marco/model/employee_model.dart';
|
|
||||||
import 'package:marco/controller/directory/directory_controller.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/model/employee_model.dart';
|
||||||
|
import 'package:marco/model/directory/contact_bucket_list_model.dart';
|
||||||
|
|
||||||
class EditBucketBottomSheet {
|
class EditBucketBottomSheet {
|
||||||
static void show(BuildContext context, ContactBucket bucket,
|
static void show(
|
||||||
List<EmployeeModel> allEmployees,
|
BuildContext context,
|
||||||
{required String ownerId}) {
|
ContactBucket bucket,
|
||||||
|
List<EmployeeModel> allEmployees, {
|
||||||
|
required String ownerId,
|
||||||
|
}) {
|
||||||
final ManageBucketController controller = Get.find();
|
final ManageBucketController controller = Get.find();
|
||||||
|
|
||||||
final nameController = TextEditingController(text: bucket.name);
|
final nameController = TextEditingController(text: bucket.name);
|
||||||
@ -25,7 +29,6 @@ class EditBucketBottomSheet {
|
|||||||
InputDecoration _inputDecoration(String label) {
|
InputDecoration _inputDecoration(String label) {
|
||||||
return InputDecoration(
|
return InputDecoration(
|
||||||
labelText: label,
|
labelText: label,
|
||||||
hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14),
|
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.grey.shade100,
|
fillColor: Colors.grey.shade100,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
@ -36,9 +39,9 @@ class EditBucketBottomSheet {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: const OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||||
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
|
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||||
),
|
),
|
||||||
contentPadding:
|
contentPadding:
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
@ -46,256 +49,183 @@ class EditBucketBottomSheet {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSubmit() async {
|
||||||
|
final newName = nameController.text.trim();
|
||||||
|
final newDesc = descController.text.trim();
|
||||||
|
final newEmployeeIds = selectedIds.toList()..sort();
|
||||||
|
final originalEmployeeIds = [...bucket.employeeIds]..sort();
|
||||||
|
|
||||||
|
final nameChanged = newName != bucket.name;
|
||||||
|
final descChanged = newDesc != bucket.description;
|
||||||
|
final employeeChanged =
|
||||||
|
!(const ListEquality().equals(newEmployeeIds, originalEmployeeIds));
|
||||||
|
|
||||||
|
if (!nameChanged && !descChanged && !employeeChanged) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "No Changes",
|
||||||
|
message: "No changes were made to update the bucket.",
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await controller.updateBucket(
|
||||||
|
id: bucket.id,
|
||||||
|
name: newName,
|
||||||
|
description: newDesc,
|
||||||
|
employeeIds: newEmployeeIds,
|
||||||
|
originalEmployeeIds: originalEmployeeIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
final directoryController = Get.find<DirectoryController>();
|
||||||
|
await directoryController.fetchBuckets();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _formContent() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: nameController,
|
||||||
|
decoration: _inputDecoration('Bucket Name'),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
TextField(
|
||||||
|
controller: descController,
|
||||||
|
maxLines: 2,
|
||||||
|
decoration: _inputDecoration('Description'),
|
||||||
|
),
|
||||||
|
MySpacing.height(20),
|
||||||
|
MyText.labelLarge('Shared With', fontWeight: 600),
|
||||||
|
MySpacing.height(8),
|
||||||
|
Obx(() => TextField(
|
||||||
|
controller: searchController,
|
||||||
|
onChanged: (value) => searchText.value = value.toLowerCase(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search employee...',
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 20),
|
||||||
|
suffixIcon: searchText.value.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear, size: 18),
|
||||||
|
onPressed: () {
|
||||||
|
searchController.clear();
|
||||||
|
searchText.value = '';
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
MySpacing.height(8),
|
||||||
|
Obx(() {
|
||||||
|
final filtered = allEmployees.where((emp) {
|
||||||
|
final fullName = '${emp.firstName} ${emp.lastName}'.toLowerCase();
|
||||||
|
return fullName.contains(searchText.value);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 180,
|
||||||
|
child: ListView.separated(
|
||||||
|
itemCount: filtered.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 2),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final emp = filtered[index];
|
||||||
|
final fullName = '${emp.firstName} ${emp.lastName}'.trim();
|
||||||
|
|
||||||
|
return Obx(() => Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
unselectedWidgetColor: Colors.grey.shade500,
|
||||||
|
checkboxTheme: CheckboxThemeData(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(4)),
|
||||||
|
side: const BorderSide(color: Colors.grey),
|
||||||
|
fillColor:
|
||||||
|
MaterialStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(MaterialState.selected)) {
|
||||||
|
return Colors.blueAccent;
|
||||||
|
}
|
||||||
|
return Colors.white;
|
||||||
|
}),
|
||||||
|
checkColor: MaterialStateProperty.all(Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: CheckboxListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
visualDensity: const VisualDensity(vertical: -4),
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
value: selectedIds.contains(emp.id),
|
||||||
|
onChanged: emp.id == ownerId
|
||||||
|
? null
|
||||||
|
: (val) {
|
||||||
|
if (val == true) {
|
||||||
|
selectedIds.add(emp.id);
|
||||||
|
} else {
|
||||||
|
selectedIds.remove(emp.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: MyText.bodyMedium(
|
||||||
|
fullName.isNotEmpty ? fullName : 'Unnamed',
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (emp.id == ownerId)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(left: 6),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: MyText.labelSmall(
|
||||||
|
"Owner",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: emp.jobRole.isNotEmpty
|
||||||
|
? MyText.bodySmall(
|
||||||
|
emp.jobRole,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return SingleChildScrollView(
|
return BaseBottomSheet(
|
||||||
padding: MediaQuery.of(context).viewInsets,
|
title: "Edit Bucket",
|
||||||
child: Container(
|
onCancel: () => Navigator.pop(context),
|
||||||
decoration: BoxDecoration(
|
onSubmit: _handleSubmit,
|
||||||
color: Theme.of(context).cardColor,
|
child: _formContent(),
|
||||||
borderRadius:
|
|
||||||
const BorderRadius.vertical(top: Radius.circular(24)),
|
|
||||||
boxShadow: const [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black12,
|
|
||||||
blurRadius: 12,
|
|
||||||
offset: Offset(0, -2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Center(
|
|
||||||
child: Container(
|
|
||||||
width: 40,
|
|
||||||
height: 5,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade300,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.height(12),
|
|
||||||
Center(
|
|
||||||
child: MyText.titleMedium('Edit Bucket', fontWeight: 700),
|
|
||||||
),
|
|
||||||
MySpacing.height(24),
|
|
||||||
|
|
||||||
// Bucket Name
|
|
||||||
TextField(
|
|
||||||
controller: nameController,
|
|
||||||
decoration: _inputDecoration('Bucket Name'),
|
|
||||||
),
|
|
||||||
MySpacing.height(16),
|
|
||||||
|
|
||||||
// Description
|
|
||||||
TextField(
|
|
||||||
controller: descController,
|
|
||||||
maxLines: 2,
|
|
||||||
decoration: _inputDecoration('Description'),
|
|
||||||
),
|
|
||||||
MySpacing.height(20),
|
|
||||||
|
|
||||||
// Shared With
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: MyText.labelLarge('Shared With', fontWeight: 600),
|
|
||||||
),
|
|
||||||
MySpacing.height(8),
|
|
||||||
|
|
||||||
// Search
|
|
||||||
Obx(() => TextField(
|
|
||||||
controller: searchController,
|
|
||||||
onChanged: (value) =>
|
|
||||||
searchText.value = value.toLowerCase(),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Search employee...',
|
|
||||||
prefixIcon: const Icon(Icons.search, size: 20),
|
|
||||||
suffixIcon: searchText.value.isNotEmpty
|
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(Icons.clear, size: 18),
|
|
||||||
onPressed: () {
|
|
||||||
searchController.clear();
|
|
||||||
searchText.value = '';
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
isDense: true,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12, vertical: 8),
|
|
||||||
filled: true,
|
|
||||||
fillColor: Colors.grey.shade100,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
focusedBorder: const OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(10)),
|
|
||||||
borderSide:
|
|
||||||
BorderSide(color: Colors.blueAccent, width: 1.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
MySpacing.height(8),
|
|
||||||
|
|
||||||
// Employee list
|
|
||||||
Obx(() {
|
|
||||||
final filtered = allEmployees.where((emp) {
|
|
||||||
final fullName =
|
|
||||||
'${emp.firstName} ${emp.lastName}'.toLowerCase();
|
|
||||||
return fullName.contains(searchText.value);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
return SizedBox(
|
|
||||||
height: 180,
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: filtered.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final emp = filtered[index];
|
|
||||||
final fullName =
|
|
||||||
'${emp.firstName} ${emp.lastName}'.trim();
|
|
||||||
|
|
||||||
return Obx(() => Theme(
|
|
||||||
data: Theme.of(context).copyWith(
|
|
||||||
checkboxTheme: CheckboxThemeData(
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
side: const BorderSide(
|
|
||||||
color: Colors.black, width: 2),
|
|
||||||
fillColor:
|
|
||||||
MaterialStateProperty.resolveWith<Color>(
|
|
||||||
(states) {
|
|
||||||
if (states
|
|
||||||
.contains(MaterialState.selected)) {
|
|
||||||
return Colors.blueAccent;
|
|
||||||
}
|
|
||||||
return Colors.transparent;
|
|
||||||
}),
|
|
||||||
checkColor:
|
|
||||||
MaterialStateProperty.all(Colors.white),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: CheckboxListTile(
|
|
||||||
dense: true,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
visualDensity:
|
|
||||||
const VisualDensity(vertical: -4),
|
|
||||||
controlAffinity:
|
|
||||||
ListTileControlAffinity.leading,
|
|
||||||
value: selectedIds.contains(emp.id),
|
|
||||||
onChanged: emp.id == ownerId
|
|
||||||
? null
|
|
||||||
: (val) {
|
|
||||||
if (val == true) {
|
|
||||||
selectedIds.add(emp.id);
|
|
||||||
} else {
|
|
||||||
selectedIds.remove(emp.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title: Text(
|
|
||||||
fullName.isNotEmpty ? fullName : 'Unnamed',
|
|
||||||
style: const TextStyle(fontSize: 13),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
|
|
||||||
MySpacing.height(24),
|
|
||||||
|
|
||||||
// Action Buttons
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: () => Get.back(),
|
|
||||||
icon: const Icon(Icons.close, color: Colors.red),
|
|
||||||
label: MyText.bodyMedium("Cancel",
|
|
||||||
color: Colors.red, fontWeight: 600),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
side: const BorderSide(color: Colors.red),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10, vertical: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.width(12),
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () async {
|
|
||||||
final newName = nameController.text.trim();
|
|
||||||
final newDesc = descController.text.trim();
|
|
||||||
final newEmployeeIds = selectedIds.toList()..sort();
|
|
||||||
final originalEmployeeIds = [...bucket.employeeIds]
|
|
||||||
..sort();
|
|
||||||
|
|
||||||
final nameChanged = newName != bucket.name;
|
|
||||||
final descChanged = newDesc != bucket.description;
|
|
||||||
final employeeChanged = !(ListEquality()
|
|
||||||
.equals(newEmployeeIds, originalEmployeeIds));
|
|
||||||
|
|
||||||
if (!nameChanged &&
|
|
||||||
!descChanged &&
|
|
||||||
!employeeChanged) {
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "No Changes",
|
|
||||||
message:
|
|
||||||
"No changes were made to update the bucket.",
|
|
||||||
type: SnackbarType.warning,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final success = await controller.updateBucket(
|
|
||||||
id: bucket.id,
|
|
||||||
name: newName,
|
|
||||||
description: newDesc,
|
|
||||||
employeeIds: newEmployeeIds,
|
|
||||||
originalEmployeeIds: originalEmployeeIds,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
final directoryController =
|
|
||||||
Get.find<DirectoryController>();
|
|
||||||
await directoryController.fetchBuckets();
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.check_circle_outline,
|
|
||||||
color: Colors.white),
|
|
||||||
label: MyText.bodyMedium("Save",
|
|
||||||
color: Colors.white, fontWeight: 600),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.indigo,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10, vertical: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
import 'package:marco/controller/dashboard/add_employee_controller.dart';
|
import 'package:marco/controller/dashboard/add_employee_controller.dart';
|
||||||
import 'package:marco/controller/dashboard/employees_screen_controller.dart';
|
import 'package:marco/controller/dashboard/employees_screen_controller.dart';
|
||||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
|
||||||
class AddEmployeeBottomSheet extends StatefulWidget {
|
class AddEmployeeBottomSheet extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
@ -17,69 +18,110 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
with UIMixin {
|
with UIMixin {
|
||||||
final AddEmployeeController _controller = Get.put(AddEmployeeController());
|
final AddEmployeeController _controller = Get.put(AddEmployeeController());
|
||||||
|
|
||||||
late TextEditingController genderController;
|
|
||||||
late TextEditingController roleController;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
Widget build(BuildContext context) {
|
||||||
super.initState();
|
return GetBuilder<AddEmployeeController>(
|
||||||
genderController = TextEditingController();
|
init: _controller,
|
||||||
roleController = TextEditingController();
|
builder: (_) {
|
||||||
}
|
return BaseBottomSheet(
|
||||||
|
title: "Add Employee",
|
||||||
RelativeRect _popupMenuPosition(BuildContext context) {
|
onCancel: () => Navigator.pop(context),
|
||||||
final RenderBox overlay =
|
onSubmit: _handleSubmit,
|
||||||
Overlay.of(context).context.findRenderObject() as RenderBox;
|
child: Form(
|
||||||
return RelativeRect.fromLTRB(100, 300, overlay.size.width - 100, 0);
|
key: _controller.basicValidator.formKey,
|
||||||
}
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
void _showGenderPopup(BuildContext context) async {
|
children: [
|
||||||
final selected = await showMenu<Gender>(
|
_sectionLabel("Personal Info"),
|
||||||
context: context,
|
MySpacing.height(16),
|
||||||
position: _popupMenuPosition(context),
|
_inputWithIcon(
|
||||||
items: Gender.values.map((gender) {
|
label: "First Name",
|
||||||
return PopupMenuItem<Gender>(
|
hint: "e.g., John",
|
||||||
value: gender,
|
icon: Icons.person,
|
||||||
child: Text(gender.name.capitalizeFirst!),
|
controller:
|
||||||
|
_controller.basicValidator.getController('first_name')!,
|
||||||
|
validator:
|
||||||
|
_controller.basicValidator.getValidation('first_name'),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_inputWithIcon(
|
||||||
|
label: "Last Name",
|
||||||
|
hint: "e.g., Doe",
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
controller:
|
||||||
|
_controller.basicValidator.getController('last_name')!,
|
||||||
|
validator:
|
||||||
|
_controller.basicValidator.getValidation('last_name'),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_sectionLabel("Contact Details"),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_buildPhoneInput(context),
|
||||||
|
MySpacing.height(24),
|
||||||
|
_sectionLabel("Other Details"),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_buildDropdownField(
|
||||||
|
label: "Gender",
|
||||||
|
value: _controller.selectedGender?.name.capitalizeFirst ?? '',
|
||||||
|
hint: "Select Gender",
|
||||||
|
onTap: () => _showGenderPopup(context),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_buildDropdownField(
|
||||||
|
label: "Role",
|
||||||
|
value: _controller.roles.firstWhereOrNull((role) =>
|
||||||
|
role['id'] == _controller.selectedRoleId)?['name'] ??
|
||||||
|
"",
|
||||||
|
hint: "Select Role",
|
||||||
|
onTap: () => _showRolePopup(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (selected != null) {
|
// Submit logic
|
||||||
_controller.onGenderSelected(selected);
|
Future<void> _handleSubmit() async {
|
||||||
|
final result = await _controller.createEmployees();
|
||||||
|
|
||||||
|
if (result != null && result['success'] == true) {
|
||||||
|
final employeeData = result['data']; // ✅ Safe now
|
||||||
|
final employeeController = Get.find<EmployeesScreenController>();
|
||||||
|
final projectId = employeeController.selectedProjectId;
|
||||||
|
|
||||||
|
if (projectId == null) {
|
||||||
|
await employeeController.fetchAllEmployees();
|
||||||
|
} else {
|
||||||
|
await employeeController.fetchEmployeesByProject(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
employeeController.update(['employee_screen_controller']);
|
||||||
|
|
||||||
|
_controller.basicValidator.getController("first_name")?.clear();
|
||||||
|
_controller.basicValidator.getController("last_name")?.clear();
|
||||||
|
_controller.basicValidator.getController("phone_number")?.clear();
|
||||||
|
_controller.selectedGender = null;
|
||||||
|
_controller.selectedRoleId = null;
|
||||||
_controller.update();
|
_controller.update();
|
||||||
|
|
||||||
|
Navigator.pop(context, employeeData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showRolePopup(BuildContext context) async {
|
// Section label widget
|
||||||
final selected = await showMenu<String>(
|
Widget _sectionLabel(String title) => Column(
|
||||||
context: context,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
position: _popupMenuPosition(context),
|
children: [
|
||||||
items: _controller.roles.map((role) {
|
MyText.labelLarge(title, fontWeight: 600),
|
||||||
return PopupMenuItem<String>(
|
MySpacing.height(4),
|
||||||
value: role['id'],
|
Divider(thickness: 1, color: Colors.grey.shade200),
|
||||||
child: Text(role['name']),
|
],
|
||||||
);
|
);
|
||||||
}).toList(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selected != null) {
|
|
||||||
_controller.onRoleSelected(selected);
|
|
||||||
_controller.update();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _sectionLabel(String title) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MyText.labelLarge(title, fontWeight: 600),
|
|
||||||
MySpacing.height(4),
|
|
||||||
Divider(thickness: 1, color: Colors.grey.shade200),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Input field with icon
|
||||||
Widget _inputWithIcon({
|
Widget _inputWithIcon({
|
||||||
required String label,
|
required String label,
|
||||||
required String hint,
|
required String hint,
|
||||||
@ -103,6 +145,124 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phone input with country code selector
|
||||||
|
Widget _buildPhoneInput(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
),
|
||||||
|
child: PopupMenuButton<Map<String, String>>(
|
||||||
|
onSelected: (country) {
|
||||||
|
_controller.selectedCountryCode = country['code']!;
|
||||||
|
_controller.update();
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
enabled: false,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 200,
|
||||||
|
width: 100,
|
||||||
|
child: ListView(
|
||||||
|
children: _controller.countries.map((country) {
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text("${country['name']} (${country['code']})"),
|
||||||
|
onTap: () => Navigator.pop(context, country),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(_controller.selectedCountryCode),
|
||||||
|
const Icon(Icons.arrow_drop_down),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.width(12),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller:
|
||||||
|
_controller.basicValidator.getController('phone_number'),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return "Phone number is required";
|
||||||
|
}
|
||||||
|
|
||||||
|
final digitsOnly = value.trim();
|
||||||
|
final minLength = _controller
|
||||||
|
.minDigitsPerCountry[_controller.selectedCountryCode] ??
|
||||||
|
7;
|
||||||
|
final maxLength = _controller
|
||||||
|
.maxDigitsPerCountry[_controller.selectedCountryCode] ??
|
||||||
|
15;
|
||||||
|
|
||||||
|
if (!RegExp(r'^[0-9]+$').hasMatch(digitsOnly)) {
|
||||||
|
return "Only digits allowed";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digitsOnly.length < minLength ||
|
||||||
|
digitsOnly.length > maxLength) {
|
||||||
|
return "Between $minLength–$maxLength digits";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(15),
|
||||||
|
],
|
||||||
|
decoration: _inputDecoration("e.g., 9876543210").copyWith(
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.contacts),
|
||||||
|
onPressed: () => _controller.pickContact(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gender/Role field (read-only dropdown)
|
||||||
|
Widget _buildDropdownField({
|
||||||
|
required String label,
|
||||||
|
required String value,
|
||||||
|
required String hint,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.labelMedium(label),
|
||||||
|
MySpacing.height(8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: AbsorbPointer(
|
||||||
|
child: TextFormField(
|
||||||
|
readOnly: true,
|
||||||
|
controller: TextEditingController(text: value),
|
||||||
|
decoration: _inputDecoration(hint).copyWith(
|
||||||
|
suffixIcon: const Icon(Icons.expand_more),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common input decoration
|
||||||
InputDecoration _inputDecoration(String hint) {
|
InputDecoration _inputDecoration(String hint) {
|
||||||
return InputDecoration(
|
return InputDecoration(
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
@ -119,302 +279,53 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
|
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||||
),
|
),
|
||||||
contentPadding: MySpacing.all(16),
|
contentPadding: MySpacing.all(16),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
// Gender popup menu
|
||||||
Widget build(BuildContext context) {
|
void _showGenderPopup(BuildContext context) async {
|
||||||
final theme = Theme.of(context);
|
final selected = await showMenu<Gender>(
|
||||||
|
context: context,
|
||||||
return GetBuilder<AddEmployeeController>(
|
position: _popupMenuPosition(context),
|
||||||
init: _controller,
|
items: Gender.values.map((gender) {
|
||||||
builder: (_) {
|
return PopupMenuItem<Gender>(
|
||||||
return SingleChildScrollView(
|
value: gender,
|
||||||
padding: MediaQuery.of(context).viewInsets,
|
child: Text(gender.name.capitalizeFirst!),
|
||||||
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: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// Drag Handle
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 5,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade300,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.height(12),
|
|
||||||
Text("Add Employee",
|
|
||||||
style: MyTextStyle.titleLarge(fontWeight: 700)),
|
|
||||||
MySpacing.height(24),
|
|
||||||
Form(
|
|
||||||
key: _controller.basicValidator.formKey,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_sectionLabel("Personal Info"),
|
|
||||||
MySpacing.height(16),
|
|
||||||
_inputWithIcon(
|
|
||||||
label: "First Name",
|
|
||||||
hint: "e.g., John",
|
|
||||||
icon: Icons.person,
|
|
||||||
controller: _controller.basicValidator
|
|
||||||
.getController('first_name')!,
|
|
||||||
validator: _controller.basicValidator
|
|
||||||
.getValidation('first_name'),
|
|
||||||
),
|
|
||||||
MySpacing.height(16),
|
|
||||||
_inputWithIcon(
|
|
||||||
label: "Last Name",
|
|
||||||
hint: "e.g., Doe",
|
|
||||||
icon: Icons.person_outline,
|
|
||||||
controller: _controller.basicValidator
|
|
||||||
.getController('last_name')!,
|
|
||||||
validator: _controller.basicValidator
|
|
||||||
.getValidation('last_name'),
|
|
||||||
),
|
|
||||||
MySpacing.height(16),
|
|
||||||
_sectionLabel("Contact Details"),
|
|
||||||
MySpacing.height(16),
|
|
||||||
MyText.labelMedium("Phone Number"),
|
|
||||||
MySpacing.height(8),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12, vertical: 14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
color: Colors.grey.shade100,
|
|
||||||
),
|
|
||||||
child: PopupMenuButton<Map<String, String>>(
|
|
||||||
onSelected: (country) {
|
|
||||||
_controller.selectedCountryCode =
|
|
||||||
country['code']!;
|
|
||||||
_controller.update();
|
|
||||||
},
|
|
||||||
itemBuilder: (context) => [
|
|
||||||
PopupMenuItem(
|
|
||||||
enabled: false,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
child: SizedBox(
|
|
||||||
height: 200,
|
|
||||||
width: 100,
|
|
||||||
child: ListView(
|
|
||||||
children: _controller.countries
|
|
||||||
.map((country) {
|
|
||||||
return ListTile(
|
|
||||||
dense: true,
|
|
||||||
title: Text(
|
|
||||||
"${country['name']} (${country['code']})"),
|
|
||||||
onTap: () =>
|
|
||||||
Navigator.pop(context, country),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(_controller.selectedCountryCode),
|
|
||||||
const Icon(Icons.arrow_drop_down),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.width(12),
|
|
||||||
Expanded(
|
|
||||||
child: TextFormField(
|
|
||||||
controller: _controller.basicValidator
|
|
||||||
.getController('phone_number'),
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.trim().isEmpty) {
|
|
||||||
return "Phone number is required";
|
|
||||||
}
|
|
||||||
|
|
||||||
final digitsOnly = value.trim();
|
|
||||||
final minLength = _controller
|
|
||||||
.minDigitsPerCountry[
|
|
||||||
_controller.selectedCountryCode] ??
|
|
||||||
7;
|
|
||||||
final maxLength = _controller
|
|
||||||
.maxDigitsPerCountry[
|
|
||||||
_controller.selectedCountryCode] ??
|
|
||||||
15;
|
|
||||||
|
|
||||||
if (!RegExp(r'^[0-9]+$')
|
|
||||||
.hasMatch(digitsOnly)) {
|
|
||||||
return "Only digits allowed";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (digitsOnly.length < minLength ||
|
|
||||||
digitsOnly.length > maxLength) {
|
|
||||||
return "Between $minLength–$maxLength digits";
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
decoration: _inputDecoration("e.g., 9876543210")
|
|
||||||
.copyWith(
|
|
||||||
suffixIcon: IconButton(
|
|
||||||
icon: const Icon(Icons.contacts),
|
|
||||||
onPressed: () =>
|
|
||||||
_controller.pickContact(context),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
MySpacing.height(24),
|
|
||||||
_sectionLabel("Other Details"),
|
|
||||||
MySpacing.height(16),
|
|
||||||
MyText.labelMedium("Gender"),
|
|
||||||
MySpacing.height(8),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => _showGenderPopup(context),
|
|
||||||
child: AbsorbPointer(
|
|
||||||
child: TextFormField(
|
|
||||||
readOnly: true,
|
|
||||||
controller: TextEditingController(
|
|
||||||
text: _controller
|
|
||||||
.selectedGender?.name.capitalizeFirst,
|
|
||||||
),
|
|
||||||
decoration:
|
|
||||||
_inputDecoration("Select Gender").copyWith(
|
|
||||||
suffixIcon: const Icon(Icons.expand_more),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.height(16),
|
|
||||||
MyText.labelMedium("Role"),
|
|
||||||
MySpacing.height(8),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => _showRolePopup(context),
|
|
||||||
child: AbsorbPointer(
|
|
||||||
child: TextFormField(
|
|
||||||
readOnly: true,
|
|
||||||
controller: TextEditingController(
|
|
||||||
text: _controller.roles.firstWhereOrNull(
|
|
||||||
(role) =>
|
|
||||||
role['id'] ==
|
|
||||||
_controller.selectedRoleId,
|
|
||||||
)?['name'] ??
|
|
||||||
"",
|
|
||||||
),
|
|
||||||
decoration:
|
|
||||||
_inputDecoration("Select Role").copyWith(
|
|
||||||
suffixIcon: const Icon(Icons.expand_more),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.height(16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
icon:
|
|
||||||
const Icon(Icons.close, color: Colors.red),
|
|
||||||
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: 20, vertical: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () async {
|
|
||||||
if (_controller.basicValidator
|
|
||||||
.validateForm()) {
|
|
||||||
final success =
|
|
||||||
await _controller.createEmployees();
|
|
||||||
if (success) {
|
|
||||||
final employeeController =
|
|
||||||
Get.find<EmployeesScreenController>();
|
|
||||||
final projectId =
|
|
||||||
employeeController.selectedProjectId;
|
|
||||||
|
|
||||||
if (projectId == null) {
|
|
||||||
await employeeController
|
|
||||||
.fetchAllEmployees();
|
|
||||||
} else {
|
|
||||||
await employeeController
|
|
||||||
.fetchEmployeesByProject(projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
employeeController.update(
|
|
||||||
['employee_screen_controller']);
|
|
||||||
|
|
||||||
_controller.basicValidator
|
|
||||||
.getController("first_name")
|
|
||||||
?.clear();
|
|
||||||
_controller.basicValidator
|
|
||||||
.getController("last_name")
|
|
||||||
?.clear();
|
|
||||||
_controller.basicValidator
|
|
||||||
.getController("phone_number")
|
|
||||||
?.clear();
|
|
||||||
_controller.selectedGender = null;
|
|
||||||
_controller.selectedRoleId = null;
|
|
||||||
_controller.update();
|
|
||||||
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.check_circle_outline,
|
|
||||||
color: Colors.white),
|
|
||||||
label: MyText.bodyMedium("Save",
|
|
||||||
color: Colors.white, fontWeight: 600),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.indigo,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12)),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 28, vertical: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
}).toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (selected != null) {
|
||||||
|
_controller.onGenderSelected(selected);
|
||||||
|
_controller.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role popup menu
|
||||||
|
void _showRolePopup(BuildContext context) async {
|
||||||
|
final selected = await showMenu<String>(
|
||||||
|
context: context,
|
||||||
|
position: _popupMenuPosition(context),
|
||||||
|
items: _controller.roles.map((role) {
|
||||||
|
return PopupMenuItem<String>(
|
||||||
|
value: role['id'],
|
||||||
|
child: Text(role['name']),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selected != null) {
|
||||||
|
_controller.onRoleSelected(selected);
|
||||||
|
_controller.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RelativeRect _popupMenuPosition(BuildContext context) {
|
||||||
|
final RenderBox overlay =
|
||||||
|
Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||||
|
return RelativeRect.fromLTRB(100, 300, overlay.size.width - 100, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
93
lib/model/employees/assigned_projects_model.dart
Normal file
93
lib/model/employees/assigned_projects_model.dart
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
class AssignedProjectsResponse {
|
||||||
|
final bool success;
|
||||||
|
final String message;
|
||||||
|
final List<AssignedProject> data;
|
||||||
|
final dynamic errors;
|
||||||
|
final int statusCode;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
AssignedProjectsResponse({
|
||||||
|
required this.success,
|
||||||
|
required this.message,
|
||||||
|
required this.data,
|
||||||
|
this.errors,
|
||||||
|
required this.statusCode,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AssignedProjectsResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AssignedProjectsResponse(
|
||||||
|
success: json['success'] ?? false,
|
||||||
|
message: json['message'] ?? '',
|
||||||
|
data: (json['data'] as List<dynamic>?)
|
||||||
|
?.map((item) => AssignedProject.fromJson(item))
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
errors: json['errors'],
|
||||||
|
statusCode: json['statusCode'] ?? 0,
|
||||||
|
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'success': success,
|
||||||
|
'message': message,
|
||||||
|
'data': data.map((p) => p.toJson()).toList(),
|
||||||
|
'errors': errors,
|
||||||
|
'statusCode': statusCode,
|
||||||
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssignedProject {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String shortName;
|
||||||
|
final String projectAddress;
|
||||||
|
final String contactPerson;
|
||||||
|
final DateTime? startDate;
|
||||||
|
final DateTime? endDate;
|
||||||
|
final String projectStatusId;
|
||||||
|
|
||||||
|
AssignedProject({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.shortName,
|
||||||
|
required this.projectAddress,
|
||||||
|
required this.contactPerson,
|
||||||
|
this.startDate,
|
||||||
|
this.endDate,
|
||||||
|
required this.projectStatusId,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AssignedProject.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AssignedProject(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
shortName: json['shortName'] ?? '',
|
||||||
|
projectAddress: json['projectAddress'] ?? '',
|
||||||
|
contactPerson: json['contactPerson'] ?? '',
|
||||||
|
startDate: json['startDate'] != null
|
||||||
|
? DateTime.tryParse(json['startDate'])
|
||||||
|
: null,
|
||||||
|
endDate:
|
||||||
|
json['endDate'] != null ? DateTime.tryParse(json['endDate']) : null,
|
||||||
|
projectStatusId: json['projectStatusId'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'shortName': shortName,
|
||||||
|
'projectAddress': projectAddress,
|
||||||
|
'contactPerson': contactPerson,
|
||||||
|
'startDate': startDate?.toIso8601String(),
|
||||||
|
'endDate': endDate?.toIso8601String(),
|
||||||
|
'projectStatusId': projectStatusId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
|
|||||||
import 'package:marco/helpers/widgets/avatar.dart';
|
import 'package:marco/helpers/widgets/avatar.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:marco/controller/dashboard/employees_screen_controller.dart';
|
import 'package:marco/controller/dashboard/employees_screen_controller.dart';
|
||||||
|
import 'package:marco/view/employees/assign_employee_bottom_sheet.dart';
|
||||||
|
|
||||||
class EmployeeDetailBottomSheet extends StatefulWidget {
|
class EmployeeDetailBottomSheet extends StatefulWidget {
|
||||||
final String employeeId;
|
final String employeeId;
|
||||||
@ -113,39 +114,80 @@ class _EmployeeDetailBottomSheetState extends State<EmployeeDetailBottomSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
MySpacing.height(20),
|
MySpacing.height(20),
|
||||||
CircleAvatar(
|
|
||||||
radius: 40,
|
// Row 1: Avatar + Name
|
||||||
backgroundColor: Colors.blueGrey[200],
|
Row(
|
||||||
child: Avatar(
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
firstName: employee.firstName,
|
children: [
|
||||||
lastName: employee.lastName,
|
CircleAvatar(
|
||||||
size: 60,
|
radius: 40,
|
||||||
),
|
backgroundColor: Colors.blueGrey[200],
|
||||||
),
|
child: Avatar(
|
||||||
MySpacing.height(12),
|
firstName: employee.firstName,
|
||||||
MyText.titleLarge(
|
lastName: employee.lastName,
|
||||||
'${employee.firstName} ${employee.lastName}',
|
size: 60,
|
||||||
fontWeight: 700,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
if (employee.jobRole.trim().isNotEmpty &&
|
|
||||||
employee.jobRole != 'null')
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 6),
|
|
||||||
child: Chip(
|
|
||||||
label: Text(
|
|
||||||
employee.jobRole,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.blueAccent,
|
),
|
||||||
|
MySpacing.width(16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.titleLarge(
|
||||||
|
'${employee.firstName} ${employee.lastName}',
|
||||||
|
fontWeight: 700,
|
||||||
|
),
|
||||||
|
MySpacing.height(6),
|
||||||
|
MyText.bodyMedium(
|
||||||
|
_getDisplayValue(employee.jobRole),
|
||||||
|
fontWeight: 500,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
MySpacing.height(12),
|
||||||
|
|
||||||
|
// Row 2: Minimal Button
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12, vertical: 6),
|
horizontal: 10, vertical: 6),
|
||||||
|
backgroundColor: Colors.blueAccent,
|
||||||
|
minimumSize: const Size(0, 32),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => AssignProjectBottomSheet(
|
||||||
|
employeeId: widget.employeeId,
|
||||||
|
jobRoleId: employee.jobRoleId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'Assign to Project',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
MySpacing.height(10),
|
),
|
||||||
|
|
||||||
|
MySpacing.height(20),
|
||||||
|
|
||||||
// Contact Info Card
|
// Contact Info Card
|
||||||
_buildInfoCard('Contact Information', [
|
_buildInfoCard('Contact Information', [
|
||||||
|
|||||||
30
lib/model/employees/employee_with_id_name_model.dart
Normal file
30
lib/model/employees/employee_with_id_name_model.dart
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
class EmployeeModelWithIdName {
|
||||||
|
final String id;
|
||||||
|
final String firstName;
|
||||||
|
final String lastName;
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
EmployeeModelWithIdName({
|
||||||
|
required this.id,
|
||||||
|
required this.firstName,
|
||||||
|
required this.lastName,
|
||||||
|
required this.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EmployeeModelWithIdName.fromJson(Map<String, dynamic> json) {
|
||||||
|
return EmployeeModelWithIdName(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
firstName: json['firstName'] ?? '',
|
||||||
|
lastName: json['lastName'] ?? '',
|
||||||
|
name: '${json['firstName'] ?? ''} ${json['lastName'] ?? ''}'.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'firstName': name.split(' ').first,
|
||||||
|
'lastName': name.split(' ').length > 1 ? name.split(' ').last : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
719
lib/model/expense/add_expense_bottom_sheet.dart
Normal file
719
lib/model/expense/add_expense_bottom_sheet.dart
Normal file
@ -0,0 +1,719 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/controller/expense/add_expense_controller.dart';
|
||||||
|
import 'package:marco/model/expense/expense_type_model.dart';
|
||||||
|
import 'package:marco/model/expense/payment_types_model.dart';
|
||||||
|
import 'package:marco/model/expense/employee_selector_bottom_sheet.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
||||||
|
|
||||||
|
Future<T?> showAddExpenseBottomSheet<T>({
|
||||||
|
bool isEdit = false,
|
||||||
|
Map<String, dynamic>? existingExpense,
|
||||||
|
}) {
|
||||||
|
return Get.bottomSheet<T>(
|
||||||
|
_AddExpenseBottomSheet(
|
||||||
|
isEdit: isEdit,
|
||||||
|
existingExpense: existingExpense,
|
||||||
|
),
|
||||||
|
isScrollControlled: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddExpenseBottomSheet extends StatefulWidget {
|
||||||
|
final bool isEdit;
|
||||||
|
final Map<String, dynamic>? existingExpense;
|
||||||
|
|
||||||
|
const _AddExpenseBottomSheet({
|
||||||
|
this.isEdit = false,
|
||||||
|
this.existingExpense,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||||
|
final AddExpenseController controller = Get.put(AddExpenseController());
|
||||||
|
final GlobalKey _projectDropdownKey = GlobalKey();
|
||||||
|
final GlobalKey _expenseTypeDropdownKey = GlobalKey();
|
||||||
|
final GlobalKey _paymentModeDropdownKey = GlobalKey();
|
||||||
|
void _showEmployeeList() async {
|
||||||
|
await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => ReusableEmployeeSelectorBottomSheet(
|
||||||
|
searchController: controller.employeeSearchController,
|
||||||
|
searchResults: controller.employeeSearchResults,
|
||||||
|
isSearching: controller.isSearchingEmployees,
|
||||||
|
onSearch: controller.searchEmployees,
|
||||||
|
onSelect: (emp) => controller.selectedPaidBy.value = emp,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optional cleanup
|
||||||
|
controller.employeeSearchController.clear();
|
||||||
|
controller.employeeSearchResults.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showOptionList<T>(
|
||||||
|
List<T> options,
|
||||||
|
String Function(T) getLabel,
|
||||||
|
ValueChanged<T> onSelected,
|
||||||
|
GlobalKey triggerKey, // add this param
|
||||||
|
) async {
|
||||||
|
final RenderBox button =
|
||||||
|
triggerKey.currentContext!.findRenderObject() as RenderBox;
|
||||||
|
final RenderBox overlay =
|
||||||
|
Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||||
|
final position = button.localToGlobal(Offset.zero, ancestor: overlay);
|
||||||
|
|
||||||
|
final selected = await showMenu<T>(
|
||||||
|
context: context,
|
||||||
|
position: RelativeRect.fromLTRB(
|
||||||
|
position.dx,
|
||||||
|
position.dy + button.size.height,
|
||||||
|
overlay.size.width - position.dx - button.size.width,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
items: options
|
||||||
|
.map(
|
||||||
|
(option) => PopupMenuItem<T>(
|
||||||
|
value: option,
|
||||||
|
child: Text(getLabel(option)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selected != null) onSelected(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(() {
|
||||||
|
return BaseBottomSheet(
|
||||||
|
title: widget.isEdit ? "Edit Expense" : "Add Expense",
|
||||||
|
isSubmitting: controller.isSubmitting.value,
|
||||||
|
onCancel: Get.back,
|
||||||
|
onSubmit: () {
|
||||||
|
if (!controller.isSubmitting.value) {
|
||||||
|
controller.submitOrUpdateExpense();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildDropdown<String>(
|
||||||
|
icon: Icons.work_outline,
|
||||||
|
title: "Project",
|
||||||
|
requiredField: true,
|
||||||
|
value: controller.selectedProject.value.isEmpty
|
||||||
|
? "Select Project"
|
||||||
|
: controller.selectedProject.value,
|
||||||
|
onTap: () => _showOptionList<String>(
|
||||||
|
controller.globalProjects.toList(),
|
||||||
|
(p) => p,
|
||||||
|
(val) => controller.selectedProject.value = val,
|
||||||
|
_projectDropdownKey, // pass the relevant GlobalKey here
|
||||||
|
),
|
||||||
|
dropdownKey: _projectDropdownKey, // pass key also here
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_buildDropdown<ExpenseTypeModel>(
|
||||||
|
icon: Icons.category_outlined,
|
||||||
|
title: "Expense Type",
|
||||||
|
requiredField: true,
|
||||||
|
value: controller.selectedExpenseType.value?.name ??
|
||||||
|
"Select Expense Type",
|
||||||
|
onTap: () => _showOptionList<ExpenseTypeModel>(
|
||||||
|
controller.expenseTypes.toList(),
|
||||||
|
(e) => e.name,
|
||||||
|
(val) => controller.selectedExpenseType.value = val,
|
||||||
|
_expenseTypeDropdownKey,
|
||||||
|
),
|
||||||
|
dropdownKey: _expenseTypeDropdownKey,
|
||||||
|
),
|
||||||
|
if (controller.selectedExpenseType.value?.noOfPersonsRequired ==
|
||||||
|
true)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.people_outline,
|
||||||
|
title: "No. of Persons",
|
||||||
|
requiredField: true,
|
||||||
|
),
|
||||||
|
MySpacing.height(6),
|
||||||
|
_CustomTextField(
|
||||||
|
controller: controller.noOfPersonsController,
|
||||||
|
hint: "Enter No. of Persons",
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.confirmation_number_outlined, title: "GST No."),
|
||||||
|
MySpacing.height(6),
|
||||||
|
_CustomTextField(
|
||||||
|
controller: controller.gstController, hint: "Enter GST No."),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_buildDropdown<PaymentModeModel>(
|
||||||
|
icon: Icons.payment,
|
||||||
|
title: "Payment Mode",
|
||||||
|
requiredField: true,
|
||||||
|
value: controller.selectedPaymentMode.value?.name ??
|
||||||
|
"Select Payment Mode",
|
||||||
|
onTap: () => _showOptionList<PaymentModeModel>(
|
||||||
|
controller.paymentModes.toList(),
|
||||||
|
(p) => p.name,
|
||||||
|
(val) => controller.selectedPaymentMode.value = val,
|
||||||
|
_paymentModeDropdownKey,
|
||||||
|
),
|
||||||
|
dropdownKey: _paymentModeDropdownKey,
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
title: "Paid By",
|
||||||
|
requiredField: true),
|
||||||
|
MySpacing.height(6),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _showEmployeeList,
|
||||||
|
child: _TileContainer(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
controller.selectedPaidBy.value == null
|
||||||
|
? "Select Paid By"
|
||||||
|
: '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}',
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
const Icon(Icons.arrow_drop_down, size: 22),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.currency_rupee,
|
||||||
|
title: "Amount",
|
||||||
|
requiredField: true),
|
||||||
|
MySpacing.height(6),
|
||||||
|
_CustomTextField(
|
||||||
|
controller: controller.amountController,
|
||||||
|
hint: "Enter Amount",
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.store_mall_directory_outlined,
|
||||||
|
title: "Supplier Name",
|
||||||
|
requiredField: true,
|
||||||
|
),
|
||||||
|
MySpacing.height(6),
|
||||||
|
_CustomTextField(
|
||||||
|
controller: controller.supplierController,
|
||||||
|
hint: "Enter Supplier Name"),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.confirmation_number_outlined,
|
||||||
|
title: "Transaction ID"),
|
||||||
|
MySpacing.height(6),
|
||||||
|
_CustomTextField(
|
||||||
|
controller: controller.transactionIdController,
|
||||||
|
hint: "Enter Transaction ID"),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.calendar_today,
|
||||||
|
title: "Transaction Date",
|
||||||
|
requiredField: true,
|
||||||
|
),
|
||||||
|
MySpacing.height(6),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => controller.pickTransactionDate(context),
|
||||||
|
child: AbsorbPointer(
|
||||||
|
child: _CustomTextField(
|
||||||
|
controller: controller.transactionDateController,
|
||||||
|
hint: "Select Transaction Date",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(icon: Icons.location_on_outlined, title: "Location"),
|
||||||
|
MySpacing.height(6),
|
||||||
|
TextField(
|
||||||
|
controller: controller.locationController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: "Enter Location",
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border:
|
||||||
|
OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
suffixIcon: controller.isFetchingLocation.value
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.my_location),
|
||||||
|
tooltip: "Use Current Location",
|
||||||
|
onPressed: controller.fetchCurrentLocation,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.attach_file,
|
||||||
|
title: "Attachments",
|
||||||
|
requiredField: true),
|
||||||
|
MySpacing.height(6),
|
||||||
|
_AttachmentsSection(
|
||||||
|
attachments: controller.attachments,
|
||||||
|
existingAttachments: controller.existingAttachments,
|
||||||
|
onRemoveNew: controller.removeAttachment,
|
||||||
|
onRemoveExisting: (item) {
|
||||||
|
final index = controller.existingAttachments.indexOf(item);
|
||||||
|
if (index != -1) {
|
||||||
|
controller.existingAttachments[index]['isActive'] = false;
|
||||||
|
controller.existingAttachments.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onAdd: controller.pickAttachments,
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.description_outlined,
|
||||||
|
title: "Description",
|
||||||
|
requiredField: true),
|
||||||
|
MySpacing.height(6),
|
||||||
|
_CustomTextField(
|
||||||
|
controller: controller.descriptionController,
|
||||||
|
hint: "Enter Description",
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDropdown<T>({
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required bool requiredField,
|
||||||
|
required String value,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
required GlobalKey dropdownKey, // new param
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_SectionTitle(icon: icon, title: title, requiredField: requiredField),
|
||||||
|
MySpacing.height(6),
|
||||||
|
_DropdownTile(
|
||||||
|
key: dropdownKey, // Pass the key here
|
||||||
|
title: value,
|
||||||
|
onTap: onTap,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionTitle extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final bool requiredField;
|
||||||
|
|
||||||
|
const _SectionTitle({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.requiredField = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = Colors.grey[700];
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
style: DefaultTextStyle.of(context).style.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(text: title),
|
||||||
|
if (requiredField)
|
||||||
|
const TextSpan(
|
||||||
|
text: ' *',
|
||||||
|
style: TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomTextField extends StatelessWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String hint;
|
||||||
|
final int maxLines;
|
||||||
|
final TextInputType keyboardType;
|
||||||
|
|
||||||
|
const _CustomTextField({
|
||||||
|
required this.controller,
|
||||||
|
required this.hint,
|
||||||
|
this.maxLines = 1,
|
||||||
|
this.keyboardType = TextInputType.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
maxLines: maxLines,
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||||
|
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: BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DropdownTile extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _DropdownTile({
|
||||||
|
required this.title,
|
||||||
|
required this.onTap,
|
||||||
|
Key? key, // Add optional key parameter
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TileContainer extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const _TileContainer({required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey.shade400),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(() {
|
||||||
|
final activeExistingAttachments =
|
||||||
|
existingAttachments.where((doc) => doc['isActive'] != false).toList();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (activeExistingAttachments.isNotEmpty) ...[
|
||||||
|
Text(
|
||||||
|
"Existing Attachments",
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: activeExistingAttachments.map((doc) {
|
||||||
|
final isImage =
|
||||||
|
doc['contentType']?.toString().startsWith('image/') ??
|
||||||
|
false;
|
||||||
|
final url = doc['url'];
|
||||||
|
final fileName = doc['fileName'] ?? 'Unnamed';
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
if (isImage) {
|
||||||
|
final imageDocs = activeExistingAttachments
|
||||||
|
.where((d) => (d['contentType']
|
||||||
|
?.toString()
|
||||||
|
.startsWith('image/') ??
|
||||||
|
false))
|
||||||
|
.toList();
|
||||||
|
final initialIndex =
|
||||||
|
imageDocs.indexWhere((d) => d == doc);
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => ImageViewerDialog(
|
||||||
|
imageSources:
|
||||||
|
imageDocs.map((e) => e['url']).toList(),
|
||||||
|
initialIndex: initialIndex,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} 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: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
),
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
// New attachments section
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
...attachments.map((file) => _AttachmentTile(
|
||||||
|
file: file,
|
||||||
|
onRemove: () => onRemoveNew(file),
|
||||||
|
)),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onAdd,
|
||||||
|
child: Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade400),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.add, size: 30, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ['jpg', 'jpeg', 'png'].contains(extension);
|
||||||
|
|
||||||
|
IconData fileIcon = Icons.insert_drive_file;
|
||||||
|
Color iconColor = Colors.blueGrey;
|
||||||
|
|
||||||
|
switch (extension) {
|
||||||
|
case 'pdf':
|
||||||
|
fileIcon = Icons.picture_as_pdf;
|
||||||
|
iconColor = Colors.redAccent;
|
||||||
|
break;
|
||||||
|
case 'doc':
|
||||||
|
case 'docx':
|
||||||
|
fileIcon = Icons.description;
|
||||||
|
iconColor = Colors.blueAccent;
|
||||||
|
break;
|
||||||
|
case 'xls':
|
||||||
|
case 'xlsx':
|
||||||
|
fileIcon = Icons.table_chart;
|
||||||
|
iconColor = Colors.green;
|
||||||
|
break;
|
||||||
|
case 'txt':
|
||||||
|
fileIcon = Icons.article;
|
||||||
|
iconColor = Colors.grey;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
),
|
||||||
|
child: isImage
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.file(file, fit: BoxFit.cover),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(fileIcon, color: iconColor, size: 30),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
extension.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: iconColor),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: -6,
|
||||||
|
right: -6,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.red, size: 18),
|
||||||
|
onPressed: onRemove,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
lib/model/expense/comment_bottom_sheet.dart
Normal file
67
lib/model/expense/comment_bottom_sheet.dart
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
|
||||||
|
Future<String?> showCommentBottomSheet(BuildContext context, String actionText) async {
|
||||||
|
final commentController = TextEditingController();
|
||||||
|
String? errorText;
|
||||||
|
|
||||||
|
return showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (context) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setModalState) {
|
||||||
|
void submit() {
|
||||||
|
final comment = commentController.text.trim();
|
||||||
|
if (comment.isEmpty) {
|
||||||
|
setModalState(() => errorText = 'Comment cannot be empty.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||||
|
child: BaseBottomSheet(
|
||||||
|
title: 'Add Comment for ${_capitalizeFirstLetter(actionText)}',
|
||||||
|
onCancel: () => Navigator.of(context).pop(),
|
||||||
|
onSubmit: submit,
|
||||||
|
isSubmitting: false,
|
||||||
|
submitText: 'Submit',
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: commentController,
|
||||||
|
maxLines: 4,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Type your comment here...',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
errorText: errorText,
|
||||||
|
),
|
||||||
|
onChanged: (_) {
|
||||||
|
if (errorText != null) {
|
||||||
|
setModalState(() => errorText = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _capitalizeFirstLetter(String text) =>
|
||||||
|
text.isEmpty ? text : text[0].toUpperCase() + text.substring(1);
|
||||||
84
lib/model/expense/employee_selector_bottom_sheet.dart
Normal file
84
lib/model/expense/employee_selector_bottom_sheet.dart
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
import 'package:marco/model/employee_model.dart';
|
||||||
|
|
||||||
|
class ReusableEmployeeSelectorBottomSheet extends StatelessWidget {
|
||||||
|
final TextEditingController searchController;
|
||||||
|
final RxList<EmployeeModel> searchResults;
|
||||||
|
final RxBool isSearching;
|
||||||
|
final void Function(String) onSearch;
|
||||||
|
final void Function(EmployeeModel) onSelect;
|
||||||
|
|
||||||
|
const ReusableEmployeeSelectorBottomSheet({
|
||||||
|
super.key,
|
||||||
|
required this.searchController,
|
||||||
|
required this.searchResults,
|
||||||
|
required this.isSearching,
|
||||||
|
required this.onSearch,
|
||||||
|
required this.onSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BaseBottomSheet(
|
||||||
|
title: "Search Employee",
|
||||||
|
onCancel: () => Get.back(),
|
||||||
|
onSubmit: () {},
|
||||||
|
showButtons: false,
|
||||||
|
child: Obx(() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: "Search by name, email...",
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
),
|
||||||
|
onChanged: onSearch,
|
||||||
|
),
|
||||||
|
MySpacing.height(12),
|
||||||
|
SizedBox(
|
||||||
|
height: 400,
|
||||||
|
child: isSearching.value
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: searchResults.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: MyText.bodyMedium(
|
||||||
|
"No employees found.",
|
||||||
|
fontWeight: 500,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: searchResults.length,
|
||||||
|
itemBuilder: (_, index) {
|
||||||
|
final emp = searchResults[index];
|
||||||
|
final fullName =
|
||||||
|
'${emp.firstName} ${emp.lastName}'.trim();
|
||||||
|
return ListTile(
|
||||||
|
title: MyText.bodyLarge(
|
||||||
|
fullName.isNotEmpty ? fullName : "Unnamed",
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
onSelect(emp);
|
||||||
|
Get.back();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
lib/model/expense/employee_selector_for_filter_bottom_sheet.dart
Normal file
128
lib/model/expense/employee_selector_for_filter_bottom_sheet.dart
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.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/model/employee_model.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
|
||||||
|
class EmployeeSelectorBottomSheet extends StatefulWidget {
|
||||||
|
final RxList<EmployeeModel> selectedEmployees;
|
||||||
|
final Future<List<EmployeeModel>> Function(String) searchEmployees;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const EmployeeSelectorBottomSheet({
|
||||||
|
super.key,
|
||||||
|
required this.selectedEmployees,
|
||||||
|
required this.searchEmployees,
|
||||||
|
this.title = "Select Employees",
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EmployeeSelectorBottomSheet> createState() =>
|
||||||
|
_EmployeeSelectorBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmployeeSelectorBottomSheetState
|
||||||
|
extends State<EmployeeSelectorBottomSheet> {
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
final RxBool isSearching = false.obs;
|
||||||
|
final RxList<EmployeeModel> searchResults = <EmployeeModel>[].obs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Initial fetch (empty text gets all/none as you wish)
|
||||||
|
_searchEmployees('');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _searchEmployees(String query) async {
|
||||||
|
isSearching.value = true;
|
||||||
|
List<EmployeeModel> results = await widget.searchEmployees(query);
|
||||||
|
searchResults.assignAll(results);
|
||||||
|
isSearching.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submitSelection() =>
|
||||||
|
Get.back(result: widget.selectedEmployees.toList());
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BaseBottomSheet(
|
||||||
|
title: widget.title,
|
||||||
|
onCancel: () => Get.back(),
|
||||||
|
onSubmit: _submitSelection,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Chips
|
||||||
|
Obx(() => widget.selectedEmployees.isEmpty
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: widget.selectedEmployees
|
||||||
|
.map(
|
||||||
|
(emp) => Chip(
|
||||||
|
label: MyText(emp.name),
|
||||||
|
onDeleted: () =>
|
||||||
|
widget.selectedEmployees.remove(emp),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
)),
|
||||||
|
MySpacing.height(8),
|
||||||
|
|
||||||
|
// Search box
|
||||||
|
TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: "Search Employees...",
|
||||||
|
border:
|
||||||
|
OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
),
|
||||||
|
onChanged: _searchEmployees,
|
||||||
|
),
|
||||||
|
MySpacing.height(12),
|
||||||
|
|
||||||
|
SizedBox(
|
||||||
|
height: 320, // CHANGE AS PER DESIGN!
|
||||||
|
child: Obx(() {
|
||||||
|
if (isSearching.value) {
|
||||||
|
return Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (searchResults.isEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.all(20),
|
||||||
|
child:
|
||||||
|
MyText('No results', style: MyTextStyle.bodyMedium()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: searchResults.length,
|
||||||
|
separatorBuilder: (_, __) => Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final emp = searchResults[index];
|
||||||
|
final isSelected = widget.selectedEmployees.contains(emp);
|
||||||
|
return ListTile(
|
||||||
|
title: MyText(emp.name),
|
||||||
|
trailing: isSelected
|
||||||
|
? Icon(Icons.check_circle, color: Colors.indigo)
|
||||||
|
: Icon(Icons.radio_button_unchecked,
|
||||||
|
color: Colors.grey),
|
||||||
|
onTap: () {
|
||||||
|
if (isSelected) {
|
||||||
|
widget.selectedEmployees.remove(emp);
|
||||||
|
} else {
|
||||||
|
widget.selectedEmployees.add(emp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
278
lib/model/expense/expense_detail_model.dart
Normal file
278
lib/model/expense/expense_detail_model.dart
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
class ExpenseDetailModel {
|
||||||
|
final String id;
|
||||||
|
final Project project;
|
||||||
|
final ExpensesType expensesType;
|
||||||
|
final PaymentMode paymentMode;
|
||||||
|
final Person paidBy;
|
||||||
|
final Person createdBy;
|
||||||
|
final String transactionDate;
|
||||||
|
final String createdAt;
|
||||||
|
final String supplerName;
|
||||||
|
final double amount;
|
||||||
|
final ExpenseStatus status;
|
||||||
|
final List<ExpenseStatus> nextStatus;
|
||||||
|
final bool preApproved;
|
||||||
|
final String transactionId;
|
||||||
|
final String description;
|
||||||
|
final String location;
|
||||||
|
final List<ExpenseDocument> documents;
|
||||||
|
final String? gstNumber;
|
||||||
|
final int noOfPersons;
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
|
ExpenseDetailModel({
|
||||||
|
required this.id,
|
||||||
|
required this.project,
|
||||||
|
required this.expensesType,
|
||||||
|
required this.paymentMode,
|
||||||
|
required this.paidBy,
|
||||||
|
required this.createdBy,
|
||||||
|
required this.transactionDate,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.supplerName,
|
||||||
|
required this.amount,
|
||||||
|
required this.status,
|
||||||
|
required this.nextStatus,
|
||||||
|
required this.preApproved,
|
||||||
|
required this.transactionId,
|
||||||
|
required this.description,
|
||||||
|
required this.location,
|
||||||
|
required this.documents,
|
||||||
|
this.gstNumber,
|
||||||
|
required this.noOfPersons,
|
||||||
|
required this.isActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ExpenseDetailModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ExpenseDetailModel(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
project: json['project'] != null ? Project.fromJson(json['project']) : Project.empty(),
|
||||||
|
expensesType: json['expensesType'] != null ? ExpensesType.fromJson(json['expensesType']) : ExpensesType.empty(),
|
||||||
|
paymentMode: json['paymentMode'] != null ? PaymentMode.fromJson(json['paymentMode']) : PaymentMode.empty(),
|
||||||
|
paidBy: json['paidBy'] != null ? Person.fromJson(json['paidBy']) : Person.empty(),
|
||||||
|
createdBy: json['createdBy'] != null ? Person.fromJson(json['createdBy']) : Person.empty(),
|
||||||
|
transactionDate: json['transactionDate'] ?? '',
|
||||||
|
createdAt: json['createdAt'] ?? '',
|
||||||
|
supplerName: json['supplerName'] ?? '',
|
||||||
|
amount: (json['amount'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
status: json['status'] != null ? ExpenseStatus.fromJson(json['status']) : ExpenseStatus.empty(),
|
||||||
|
nextStatus: (json['nextStatus'] as List?)?.map((e) => ExpenseStatus.fromJson(e)).toList() ?? [],
|
||||||
|
preApproved: json['preApproved'] ?? false,
|
||||||
|
transactionId: json['transactionId'] ?? '',
|
||||||
|
description: json['description'] ?? '',
|
||||||
|
location: json['location'] ?? '',
|
||||||
|
documents: (json['documents'] as List?)?.map((e) => ExpenseDocument.fromJson(e)).toList() ?? [],
|
||||||
|
gstNumber: json['gstNumber']?.toString(),
|
||||||
|
noOfPersons: json['noOfPersons'] ?? 0,
|
||||||
|
isActive: json['isActive'] ?? true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Project {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String shortName;
|
||||||
|
final String projectAddress;
|
||||||
|
final String contactPerson;
|
||||||
|
final String startDate;
|
||||||
|
final String endDate;
|
||||||
|
final String projectStatusId;
|
||||||
|
|
||||||
|
Project({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.shortName,
|
||||||
|
required this.projectAddress,
|
||||||
|
required this.contactPerson,
|
||||||
|
required this.startDate,
|
||||||
|
required this.endDate,
|
||||||
|
required this.projectStatusId,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Project.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Project(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
shortName: json['shortName'] ?? '',
|
||||||
|
projectAddress: json['projectAddress'] ?? '',
|
||||||
|
contactPerson: json['contactPerson'] ?? '',
|
||||||
|
startDate: json['startDate'] ?? '',
|
||||||
|
endDate: json['endDate'] ?? '',
|
||||||
|
projectStatusId: json['projectStatusId'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Project.empty() => Project(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
shortName: '',
|
||||||
|
projectAddress: '',
|
||||||
|
contactPerson: '',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
projectStatusId: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExpensesType {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final bool noOfPersonsRequired;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
ExpensesType({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.noOfPersonsRequired,
|
||||||
|
required this.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ExpensesType.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ExpensesType(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
noOfPersonsRequired: json['noOfPersonsRequired'] ?? false,
|
||||||
|
description: json['description'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory ExpensesType.empty() => ExpensesType(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
noOfPersonsRequired: false,
|
||||||
|
description: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class PaymentMode {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
PaymentMode({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PaymentMode.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PaymentMode(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
description: json['description'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory PaymentMode.empty() => PaymentMode(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Person {
|
||||||
|
final String id;
|
||||||
|
final String firstName;
|
||||||
|
final String lastName;
|
||||||
|
final String photo;
|
||||||
|
final String jobRoleId;
|
||||||
|
final String jobRoleName;
|
||||||
|
|
||||||
|
Person({
|
||||||
|
required this.id,
|
||||||
|
required this.firstName,
|
||||||
|
required this.lastName,
|
||||||
|
required this.photo,
|
||||||
|
required this.jobRoleId,
|
||||||
|
required this.jobRoleName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Person.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Person(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
firstName: json['firstName'] ?? '',
|
||||||
|
lastName: json['lastName'] ?? '',
|
||||||
|
photo: json['photo'] is String ? json['photo'] : '',
|
||||||
|
jobRoleId: json['jobRoleId'] ?? '',
|
||||||
|
jobRoleName: json['jobRoleName'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Person.empty() => Person(
|
||||||
|
id: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
photo: '',
|
||||||
|
jobRoleId: '',
|
||||||
|
jobRoleName: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExpenseStatus {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String displayName;
|
||||||
|
final String description;
|
||||||
|
final String? permissionIds;
|
||||||
|
final String color;
|
||||||
|
final bool isSystem;
|
||||||
|
|
||||||
|
ExpenseStatus({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.displayName,
|
||||||
|
required this.description,
|
||||||
|
required this.permissionIds,
|
||||||
|
required this.color,
|
||||||
|
required this.isSystem,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ExpenseStatus.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ExpenseStatus(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
displayName: json['displayName'] ?? '',
|
||||||
|
description: json['description'] ?? '',
|
||||||
|
permissionIds: json['permissionIds']?.toString(),
|
||||||
|
color: json['color'] ?? '',
|
||||||
|
isSystem: json['isSystem'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory ExpenseStatus.empty() => ExpenseStatus(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
displayName: '',
|
||||||
|
description: '',
|
||||||
|
permissionIds: null,
|
||||||
|
color: '',
|
||||||
|
isSystem: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExpenseDocument {
|
||||||
|
final String documentId;
|
||||||
|
final String fileName;
|
||||||
|
final String contentType;
|
||||||
|
final String preSignedUrl;
|
||||||
|
final String thumbPreSignedUrl;
|
||||||
|
|
||||||
|
ExpenseDocument({
|
||||||
|
required this.documentId,
|
||||||
|
required this.fileName,
|
||||||
|
required this.contentType,
|
||||||
|
required this.preSignedUrl,
|
||||||
|
required this.thumbPreSignedUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ExpenseDocument.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ExpenseDocument(
|
||||||
|
documentId: json['documentId'] ?? '',
|
||||||
|
fileName: json['fileName'] ?? '',
|
||||||
|
contentType: json['contentType'] ?? '',
|
||||||
|
preSignedUrl: json['preSignedUrl'] ?? '',
|
||||||
|
thumbPreSignedUrl: json['thumbPreSignedUrl'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
405
lib/model/expense/expense_list_model.dart
Normal file
405
lib/model/expense/expense_list_model.dart
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// Parse the entire response
|
||||||
|
ExpenseResponse expenseResponseFromJson(String str) =>
|
||||||
|
ExpenseResponse.fromJson(json.decode(str));
|
||||||
|
|
||||||
|
String expenseResponseToJson(ExpenseResponse data) =>
|
||||||
|
json.encode(data.toJson());
|
||||||
|
|
||||||
|
class ExpenseResponse {
|
||||||
|
final bool success;
|
||||||
|
final String message;
|
||||||
|
final ExpenseData data;
|
||||||
|
final dynamic errors;
|
||||||
|
final int statusCode;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
ExpenseResponse({
|
||||||
|
required this.success,
|
||||||
|
required this.message,
|
||||||
|
required this.data,
|
||||||
|
required this.errors,
|
||||||
|
required this.statusCode,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ExpenseResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
final dataField = json["data"];
|
||||||
|
return ExpenseResponse(
|
||||||
|
success: json["success"] ?? false,
|
||||||
|
message: json["message"] ?? '',
|
||||||
|
data: (dataField is Map<String, dynamic>)
|
||||||
|
? ExpenseData.fromJson(dataField)
|
||||||
|
: ExpenseData.empty(),
|
||||||
|
errors: json["errors"],
|
||||||
|
statusCode: json["statusCode"] ?? 0,
|
||||||
|
timestamp: DateTime.tryParse(json["timestamp"] ?? '') ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"success": success,
|
||||||
|
"message": message,
|
||||||
|
"data": data.toJson(),
|
||||||
|
"errors": errors,
|
||||||
|
"statusCode": statusCode,
|
||||||
|
"timestamp": timestamp.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExpenseData {
|
||||||
|
final Filter? filter;
|
||||||
|
final int currentPage;
|
||||||
|
final int totalPages;
|
||||||
|
final int totalEntites;
|
||||||
|
final List<ExpenseModel> data;
|
||||||
|
|
||||||
|
ExpenseData({
|
||||||
|
required this.filter,
|
||||||
|
required this.currentPage,
|
||||||
|
required this.totalPages,
|
||||||
|
required this.totalEntites,
|
||||||
|
required this.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ExpenseData.fromJson(Map<String, dynamic> json) => ExpenseData(
|
||||||
|
filter: json["filter"] != null ? Filter.fromJson(json["filter"]) : null,
|
||||||
|
currentPage: json["currentPage"] ?? 0,
|
||||||
|
totalPages: json["totalPages"] ?? 0,
|
||||||
|
totalEntites: json["totalEntites"] ?? 0,
|
||||||
|
data: (json["data"] as List<dynamic>? ?? [])
|
||||||
|
.map((x) => ExpenseModel.fromJson(x))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
factory ExpenseData.empty() => ExpenseData(
|
||||||
|
filter: null,
|
||||||
|
currentPage: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
totalEntites: 0,
|
||||||
|
data: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"filter": filter?.toJson(),
|
||||||
|
"currentPage": currentPage,
|
||||||
|
"totalPages": totalPages,
|
||||||
|
"totalEntites": totalEntites,
|
||||||
|
"data": List<dynamic>.from(data.map((x) => x.toJson())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class Filter {
|
||||||
|
final List<String> projectIds;
|
||||||
|
final List<String> statusIds;
|
||||||
|
final List<String> createdByIds;
|
||||||
|
final List<String> paidById;
|
||||||
|
final DateTime? startDate;
|
||||||
|
final DateTime? endDate;
|
||||||
|
|
||||||
|
Filter({
|
||||||
|
required this.projectIds,
|
||||||
|
required this.statusIds,
|
||||||
|
required this.createdByIds,
|
||||||
|
required this.paidById,
|
||||||
|
required this.startDate,
|
||||||
|
required this.endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Filter.fromJson(Map<String, dynamic> json) => Filter(
|
||||||
|
projectIds: List<String>.from(json["projectIds"] ?? []),
|
||||||
|
statusIds: List<String>.from(json["statusIds"] ?? []),
|
||||||
|
createdByIds: List<String>.from(json["createdByIds"] ?? []),
|
||||||
|
paidById: List<String>.from(json["paidById"] ?? []),
|
||||||
|
startDate:
|
||||||
|
json["startDate"] != null ? DateTime.tryParse(json["startDate"]) : null,
|
||||||
|
endDate:
|
||||||
|
json["endDate"] != null ? DateTime.tryParse(json["endDate"]) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"projectIds": projectIds,
|
||||||
|
"statusIds": statusIds,
|
||||||
|
"createdByIds": createdByIds,
|
||||||
|
"paidById": paidById,
|
||||||
|
"startDate": startDate?.toIso8601String(),
|
||||||
|
"endDate": endDate?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ExpenseModel and other classes remain same as you wrote ---
|
||||||
|
// I will include them here for completeness.
|
||||||
|
|
||||||
|
class ExpenseModel {
|
||||||
|
final String id;
|
||||||
|
final Project project;
|
||||||
|
final ExpenseType expensesType;
|
||||||
|
final PaymentMode paymentMode;
|
||||||
|
final PaidBy paidBy;
|
||||||
|
final CreatedBy createdBy;
|
||||||
|
final DateTime transactionDate;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final String supplerName;
|
||||||
|
final double amount;
|
||||||
|
final Status status;
|
||||||
|
final List<Status> nextStatus;
|
||||||
|
final bool preApproved;
|
||||||
|
|
||||||
|
ExpenseModel({
|
||||||
|
required this.id,
|
||||||
|
required this.project,
|
||||||
|
required this.expensesType,
|
||||||
|
required this.paymentMode,
|
||||||
|
required this.paidBy,
|
||||||
|
required this.createdBy,
|
||||||
|
required this.transactionDate,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.supplerName,
|
||||||
|
required this.amount,
|
||||||
|
required this.status,
|
||||||
|
required this.nextStatus,
|
||||||
|
required this.preApproved,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ExpenseModel.fromJson(Map<String, dynamic> json) => ExpenseModel(
|
||||||
|
id: json["id"] ?? '',
|
||||||
|
project: Project.fromJson(json["project"] ?? {}),
|
||||||
|
expensesType: ExpenseType.fromJson(json["expensesType"] ?? {}),
|
||||||
|
paymentMode: PaymentMode.fromJson(json["paymentMode"] ?? {}),
|
||||||
|
paidBy: PaidBy.fromJson(json["paidBy"] ?? {}),
|
||||||
|
createdBy: CreatedBy.fromJson(json["createdBy"] ?? {}),
|
||||||
|
transactionDate:
|
||||||
|
DateTime.tryParse(json["transactionDate"] ?? '') ?? DateTime.now(),
|
||||||
|
createdAt:
|
||||||
|
DateTime.tryParse(json["createdAt"] ?? '') ?? DateTime.now(),
|
||||||
|
supplerName: json["supplerName"] ?? '',
|
||||||
|
amount: (json["amount"] ?? 0).toDouble(),
|
||||||
|
status: Status.fromJson(json["status"] ?? {}),
|
||||||
|
nextStatus: (json["nextStatus"] as List<dynamic>? ?? [])
|
||||||
|
.map((x) => Status.fromJson(x))
|
||||||
|
.toList(),
|
||||||
|
preApproved: json["preApproved"] ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"id": id,
|
||||||
|
"project": project.toJson(),
|
||||||
|
"expensesType": expensesType.toJson(),
|
||||||
|
"paymentMode": paymentMode.toJson(),
|
||||||
|
"paidBy": paidBy.toJson(),
|
||||||
|
"createdBy": createdBy.toJson(),
|
||||||
|
"transactionDate": transactionDate.toIso8601String(),
|
||||||
|
"createdAt": createdAt.toIso8601String(),
|
||||||
|
"supplerName": supplerName,
|
||||||
|
"amount": amount,
|
||||||
|
"status": status.toJson(),
|
||||||
|
"nextStatus": List<dynamic>.from(nextStatus.map((x) => x.toJson())),
|
||||||
|
"preApproved": preApproved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class Project {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String shortName;
|
||||||
|
final String projectAddress;
|
||||||
|
final String contactPerson;
|
||||||
|
final DateTime startDate;
|
||||||
|
final DateTime endDate;
|
||||||
|
final String projectStatusId;
|
||||||
|
|
||||||
|
Project({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.shortName,
|
||||||
|
required this.projectAddress,
|
||||||
|
required this.contactPerson,
|
||||||
|
required this.startDate,
|
||||||
|
required this.endDate,
|
||||||
|
required this.projectStatusId,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Project.fromJson(Map<String, dynamic> json) => Project(
|
||||||
|
id: json["id"] ?? '',
|
||||||
|
name: json["name"] ?? '',
|
||||||
|
shortName: json["shortName"] ?? '',
|
||||||
|
projectAddress: json["projectAddress"] ?? '',
|
||||||
|
contactPerson: json["contactPerson"] ?? '',
|
||||||
|
startDate:
|
||||||
|
DateTime.tryParse(json["startDate"] ?? '') ?? DateTime.now(),
|
||||||
|
endDate: DateTime.tryParse(json["endDate"] ?? '') ?? DateTime.now(),
|
||||||
|
projectStatusId: json["projectStatusId"] ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"shortName": shortName,
|
||||||
|
"projectAddress": projectAddress,
|
||||||
|
"contactPerson": contactPerson,
|
||||||
|
"startDate": startDate.toIso8601String(),
|
||||||
|
"endDate": endDate.toIso8601String(),
|
||||||
|
"projectStatusId": projectStatusId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExpenseType {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final bool noOfPersonsRequired;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
ExpenseType({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.noOfPersonsRequired,
|
||||||
|
required this.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ExpenseType.fromJson(Map<String, dynamic> json) => ExpenseType(
|
||||||
|
id: json["id"] ?? '',
|
||||||
|
name: json["name"] ?? '',
|
||||||
|
noOfPersonsRequired: json["noOfPersonsRequired"] ?? false,
|
||||||
|
description: json["description"] ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"noOfPersonsRequired": noOfPersonsRequired,
|
||||||
|
"description": description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class PaymentMode {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
PaymentMode({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PaymentMode.fromJson(Map<String, dynamic> json) => PaymentMode(
|
||||||
|
id: json["id"] ?? '',
|
||||||
|
name: json["name"] ?? '',
|
||||||
|
description: json["description"] ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class PaidBy {
|
||||||
|
final String id;
|
||||||
|
final String firstName;
|
||||||
|
final String lastName;
|
||||||
|
final String photo;
|
||||||
|
final String jobRoleId;
|
||||||
|
final String? jobRoleName;
|
||||||
|
|
||||||
|
PaidBy({
|
||||||
|
required this.id,
|
||||||
|
required this.firstName,
|
||||||
|
required this.lastName,
|
||||||
|
required this.photo,
|
||||||
|
required this.jobRoleId,
|
||||||
|
this.jobRoleName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PaidBy.fromJson(Map<String, dynamic> json) => PaidBy(
|
||||||
|
id: json["id"] ?? '',
|
||||||
|
firstName: json["firstName"] ?? '',
|
||||||
|
lastName: json["lastName"] ?? '',
|
||||||
|
photo: json["photo"] ?? '',
|
||||||
|
jobRoleId: json["jobRoleId"] ?? '',
|
||||||
|
jobRoleName: json["jobRoleName"],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"id": id,
|
||||||
|
"firstName": firstName,
|
||||||
|
"lastName": lastName,
|
||||||
|
"photo": photo,
|
||||||
|
"jobRoleId": jobRoleId,
|
||||||
|
"jobRoleName": jobRoleName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreatedBy {
|
||||||
|
final String id;
|
||||||
|
final String firstName;
|
||||||
|
final String lastName;
|
||||||
|
final String photo;
|
||||||
|
final String jobRoleId;
|
||||||
|
final String? jobRoleName;
|
||||||
|
|
||||||
|
CreatedBy({
|
||||||
|
required this.id,
|
||||||
|
required this.firstName,
|
||||||
|
required this.lastName,
|
||||||
|
required this.photo,
|
||||||
|
required this.jobRoleId,
|
||||||
|
this.jobRoleName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CreatedBy.fromJson(Map<String, dynamic> json) => CreatedBy(
|
||||||
|
id: json["id"] ?? '',
|
||||||
|
firstName: json["firstName"] ?? '',
|
||||||
|
lastName: json["lastName"] ?? '',
|
||||||
|
photo: json["photo"] ?? '',
|
||||||
|
jobRoleId: json["jobRoleId"] ?? '',
|
||||||
|
jobRoleName: json["jobRoleName"],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"id": id,
|
||||||
|
"firstName": firstName,
|
||||||
|
"lastName": lastName,
|
||||||
|
"photo": photo,
|
||||||
|
"jobRoleId": jobRoleId,
|
||||||
|
"jobRoleName": jobRoleName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class Status {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String displayName;
|
||||||
|
final String description;
|
||||||
|
final String color;
|
||||||
|
final bool isSystem;
|
||||||
|
|
||||||
|
Status({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.displayName,
|
||||||
|
required this.description,
|
||||||
|
required this.color,
|
||||||
|
required this.isSystem,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Status.fromJson(Map<String, dynamic> json) => Status(
|
||||||
|
id: json["id"] ?? '',
|
||||||
|
name: json["name"] ?? '',
|
||||||
|
displayName: json["displayName"] ?? '',
|
||||||
|
description: json["description"] ?? '',
|
||||||
|
color: (json["color"] ?? '').replaceAll("'", ''),
|
||||||
|
isSystem: json["isSystem"] ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"displayName": displayName,
|
||||||
|
"description": description,
|
||||||
|
"color": color,
|
||||||
|
"isSystem": isSystem,
|
||||||
|
};
|
||||||
|
}
|
||||||
25
lib/model/expense/expense_status_model.dart
Normal file
25
lib/model/expense/expense_status_model.dart
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
class ExpenseStatusModel {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String description;
|
||||||
|
final bool isSystem;
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
|
ExpenseStatusModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
required this.isSystem,
|
||||||
|
required this.isActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ExpenseStatusModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ExpenseStatusModel(
|
||||||
|
id: json['id'],
|
||||||
|
name: json['name'],
|
||||||
|
description: json['description'] ?? '',
|
||||||
|
isSystem: json['isSystem'] ?? false,
|
||||||
|
isActive: json['isActive'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
lib/model/expense/expense_type_model.dart
Normal file
25
lib/model/expense/expense_type_model.dart
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
class ExpenseTypeModel {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final bool noOfPersonsRequired;
|
||||||
|
final String description;
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
|
ExpenseTypeModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.noOfPersonsRequired,
|
||||||
|
required this.description,
|
||||||
|
required this.isActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ExpenseTypeModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ExpenseTypeModel(
|
||||||
|
id: json['id'],
|
||||||
|
name: json['name'],
|
||||||
|
noOfPersonsRequired: json['noOfPersonsRequired'] ?? false,
|
||||||
|
description: json['description'] ?? '',
|
||||||
|
isActive: json['isActive'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
lib/model/expense/payment_types_model.dart
Normal file
22
lib/model/expense/payment_types_model.dart
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
class PaymentModeModel {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String description;
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
|
PaymentModeModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
required this.isActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PaymentModeModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PaymentModeModel(
|
||||||
|
id: json['id'],
|
||||||
|
name: json['name'],
|
||||||
|
description: json['description'] ?? '',
|
||||||
|
isActive: json['isActive'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
217
lib/model/expense/reimbursement_bottom_sheet.dart
Normal file
217
lib/model/expense/reimbursement_bottom_sheet.dart
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:marco/controller/expense/expense_detail_controller.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/utils/base_bottom_sheet.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/model/expense/employee_selector_bottom_sheet.dart';
|
||||||
|
|
||||||
|
class ReimbursementBottomSheet extends StatefulWidget {
|
||||||
|
final String expenseId;
|
||||||
|
final String statusId;
|
||||||
|
final void Function() onClose;
|
||||||
|
final Future<bool> Function({
|
||||||
|
required String comment,
|
||||||
|
required String reimburseTransactionId,
|
||||||
|
required String reimburseDate,
|
||||||
|
required String reimburseById,
|
||||||
|
required String statusId,
|
||||||
|
}) onSubmit;
|
||||||
|
|
||||||
|
const ReimbursementBottomSheet({
|
||||||
|
super.key,
|
||||||
|
required this.expenseId,
|
||||||
|
required this.onClose,
|
||||||
|
required this.onSubmit,
|
||||||
|
required this.statusId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ReimbursementBottomSheet> createState() =>
|
||||||
|
_ReimbursementBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
|
||||||
|
final ExpenseDetailController controller =
|
||||||
|
Get.find<ExpenseDetailController>();
|
||||||
|
|
||||||
|
final TextEditingController commentCtrl = TextEditingController();
|
||||||
|
final TextEditingController txnCtrl = TextEditingController();
|
||||||
|
final RxString dateStr = ''.obs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
commentCtrl.dispose();
|
||||||
|
txnCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showEmployeeList() async {
|
||||||
|
await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => ReusableEmployeeSelectorBottomSheet(
|
||||||
|
searchController: controller.employeeSearchController,
|
||||||
|
searchResults: controller.employeeSearchResults,
|
||||||
|
isSearching: controller.isSearchingEmployees,
|
||||||
|
onSearch: controller.searchEmployees,
|
||||||
|
onSelect: (emp) => controller.selectedReimbursedBy.value = emp,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optional cleanup
|
||||||
|
controller.employeeSearchController.clear();
|
||||||
|
controller.employeeSearchResults.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _inputDecoration(String hint) {
|
||||||
|
return InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||||
|
),
|
||||||
|
contentPadding: MySpacing.all(16),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(() {
|
||||||
|
return BaseBottomSheet(
|
||||||
|
title: "Reimbursement Info",
|
||||||
|
isSubmitting: controller.isLoading.value,
|
||||||
|
onCancel: () {
|
||||||
|
widget.onClose();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
onSubmit: () async {
|
||||||
|
if (commentCtrl.text.trim().isEmpty ||
|
||||||
|
txnCtrl.text.trim().isEmpty ||
|
||||||
|
dateStr.value.isEmpty ||
|
||||||
|
controller.selectedReimbursedBy.value == null) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Incomplete",
|
||||||
|
message: "Please fill all fields",
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await widget.onSubmit(
|
||||||
|
comment: commentCtrl.text.trim(),
|
||||||
|
reimburseTransactionId: txnCtrl.text.trim(),
|
||||||
|
reimburseDate: dateStr.value,
|
||||||
|
reimburseById: controller.selectedReimbursedBy.value!.id,
|
||||||
|
statusId: widget.statusId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
Get.back();
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Success",
|
||||||
|
message: "Reimbursement submitted",
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: controller.errorMessage.value,
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.labelMedium("Comment"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextField(
|
||||||
|
controller: commentCtrl,
|
||||||
|
decoration: _inputDecoration("Enter comment"),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
MyText.labelMedium("Transaction ID"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextField(
|
||||||
|
controller: txnCtrl,
|
||||||
|
decoration: _inputDecoration("Enter transaction ID"),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
MyText.labelMedium("Reimbursement Date"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: dateStr.value.isEmpty
|
||||||
|
? DateTime.now()
|
||||||
|
: DateFormat('yyyy-MM-dd').parse(dateStr.value),
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime(2100),
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
dateStr.value = DateFormat('yyyy-MM-dd').format(picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: AbsorbPointer(
|
||||||
|
child: TextField(
|
||||||
|
controller: TextEditingController(text: dateStr.value),
|
||||||
|
decoration: _inputDecoration("Select Date").copyWith(
|
||||||
|
suffixIcon: const Icon(Icons.calendar_today),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
MyText.labelMedium("Reimbursed By"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _showEmployeeList,
|
||||||
|
child: Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
controller.selectedReimbursedBy.value == null
|
||||||
|
? "Select Paid By"
|
||||||
|
: '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}',
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
const Icon(Icons.arrow_drop_down, size: 22),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ import 'package:marco/view/auth/login_option_screen.dart';
|
|||||||
import 'package:marco/view/auth/mpin_screen.dart';
|
import 'package:marco/view/auth/mpin_screen.dart';
|
||||||
import 'package:marco/view/auth/mpin_auth_screen.dart';
|
import 'package:marco/view/auth/mpin_auth_screen.dart';
|
||||||
import 'package:marco/view/directory/directory_main_screen.dart';
|
import 'package:marco/view/directory/directory_main_screen.dart';
|
||||||
|
import 'package:marco/view/expense/expense_screen.dart';
|
||||||
|
|
||||||
class AuthMiddleware extends GetMiddleware {
|
class AuthMiddleware extends GetMiddleware {
|
||||||
@override
|
@override
|
||||||
@ -65,6 +66,11 @@ getPageRoute() {
|
|||||||
name: '/dashboard/directory-main-page',
|
name: '/dashboard/directory-main-page',
|
||||||
page: () => DirectoryMainScreen(),
|
page: () => DirectoryMainScreen(),
|
||||||
middlewares: [AuthMiddleware()]),
|
middlewares: [AuthMiddleware()]),
|
||||||
|
// Expense
|
||||||
|
GetPage(
|
||||||
|
name: '/dashboard/expense-main-page',
|
||||||
|
page: () => ExpenseMainScreen(),
|
||||||
|
middlewares: [AuthMiddleware()]),
|
||||||
// Authentication
|
// Authentication
|
||||||
GetPage(name: '/auth/login', page: () => LoginScreen()),
|
GetPage(name: '/auth/login', page: () => LoginScreen()),
|
||||||
GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()),
|
GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()),
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:marco/controller/auth/forgot_password_controller.dart';
|
import 'package:marco/controller/auth/forgot_password_controller.dart';
|
||||||
import 'package:marco/helpers/widgets/my_button.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_text.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/utils/mixins/ui_mixin.dart';
|
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_button.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
import 'package:marco/images.dart';
|
import 'package:marco/images.dart';
|
||||||
|
|
||||||
class ForgotPasswordScreen extends StatefulWidget {
|
class ForgotPasswordScreen extends StatefulWidget {
|
||||||
@ -22,10 +21,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
|
|||||||
final ForgotPasswordController controller =
|
final ForgotPasswordController controller =
|
||||||
Get.put(ForgotPasswordController());
|
Get.put(ForgotPasswordController());
|
||||||
|
|
||||||
late AnimationController _controller;
|
late final AnimationController _controller;
|
||||||
late Animation<double> _logoAnimation;
|
late final Animation<double> _logoAnimation;
|
||||||
|
|
||||||
bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage");
|
final bool _isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage");
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -64,29 +63,9 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
|
|||||||
SafeArea(
|
SafeArea(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
ScaleTransition(
|
_buildAnimatedLogo(),
|
||||||
scale: _logoAnimation,
|
|
||||||
child: Container(
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: const [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black12,
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Image.asset(Images.logoDark),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@ -96,36 +75,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
MyText(
|
_buildWelcomeText(),
|
||||||
"Welcome to Marco",
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: Colors.black87,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
MyText(
|
|
||||||
"Streamline Project Management\nBoost Productivity with Automation.",
|
|
||||||
fontSize: 14,
|
|
||||||
color: Colors.black54,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
if (_isBetaEnvironment) ...[
|
if (_isBetaEnvironment) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Container(
|
_buildBetaBadge(),
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.orangeAccent,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
child: MyText(
|
|
||||||
'BETA',
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
const SizedBox(height: 36),
|
const SizedBox(height: 36),
|
||||||
_buildForgotCard(),
|
_buildForgotCard(),
|
||||||
@ -143,6 +96,66 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildAnimatedLogo() {
|
||||||
|
return ScaleTransition(
|
||||||
|
scale: _logoAnimation,
|
||||||
|
child: Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black12,
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Image.asset(Images.logoDark),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWelcomeText() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
MyText(
|
||||||
|
"Welcome to Marco",
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.black87,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
MyText(
|
||||||
|
"Streamline Project Management\nBoost Productivity with Automation.",
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.black54,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBetaBadge() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orangeAccent,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: MyText(
|
||||||
|
'BETA',
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildForgotCard() {
|
Widget _buildForgotCard() {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
@ -165,7 +178,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
|
|||||||
MyText(
|
MyText(
|
||||||
'Forgot Password',
|
'Forgot Password',
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: 700,
|
fontWeight: 600,
|
||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@ -177,70 +190,80 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
TextFormField(
|
_buildEmailInput(),
|
||||||
validator: controller.basicValidator.getValidation('email'),
|
|
||||||
controller: controller.basicValidator.getController('email'),
|
|
||||||
keyboardType: TextInputType.emailAddress,
|
|
||||||
style: const TextStyle(fontSize: 14),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: "Email Address",
|
|
||||||
labelStyle: const TextStyle(color: Colors.black54),
|
|
||||||
filled: true,
|
|
||||||
fillColor: Colors.grey.shade100,
|
|
||||||
prefixIcon: const Icon(LucideIcons.mail, size: 20),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
MyButton.rounded(
|
_buildResetButton(),
|
||||||
onPressed: _isLoading ? null : _handleForgotPassword,
|
|
||||||
elevation: 2,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16),
|
|
||||||
borderRadiusAll: 10,
|
|
||||||
backgroundColor: _isLoading
|
|
||||||
? contentTheme.brandRed.withOpacity(0.6)
|
|
||||||
: contentTheme.brandRed,
|
|
||||||
child: _isLoading
|
|
||||||
? const SizedBox(
|
|
||||||
height: 20,
|
|
||||||
width: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: Colors.white,
|
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: MyText.bodyMedium(
|
|
||||||
'Send Reset Link',
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
TextButton.icon(
|
_buildBackButton(),
|
||||||
onPressed: () async => await LocalStorage.logout(),
|
|
||||||
icon: const Icon(Icons.arrow_back,
|
|
||||||
size: 18, color: Colors.redAccent),
|
|
||||||
label: MyText.bodyMedium(
|
|
||||||
'Back to Login',
|
|
||||||
color: contentTheme.brandRed,
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildEmailInput() {
|
||||||
|
return TextFormField(
|
||||||
|
validator: controller.basicValidator.getValidation('email'),
|
||||||
|
controller: controller.basicValidator.getController('email'),
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: "Email Address",
|
||||||
|
labelStyle: const TextStyle(color: Colors.black54),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
prefixIcon: const Icon(LucideIcons.mail, size: 20),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildResetButton() {
|
||||||
|
return MyButton.rounded(
|
||||||
|
onPressed: _isLoading ? null : _handleForgotPassword,
|
||||||
|
elevation: 2,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16),
|
||||||
|
borderRadiusAll: 10,
|
||||||
|
backgroundColor: _isLoading
|
||||||
|
? contentTheme.brandRed.withOpacity(0.6)
|
||||||
|
: contentTheme.brandRed,
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: MyText.bodyMedium(
|
||||||
|
'Send Reset Link',
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBackButton() {
|
||||||
|
return TextButton.icon(
|
||||||
|
onPressed: () async => await LocalStorage.logout(),
|
||||||
|
icon: const Icon(Icons.arrow_back, size: 18, color: Colors.redAccent),
|
||||||
|
label: MyText.bodyMedium(
|
||||||
|
'Back to Login',
|
||||||
|
color: contentTheme.brandRed,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Red background using dynamic brandRed
|
|
||||||
class _RedWaveBackground extends StatelessWidget {
|
class _RedWaveBackground extends StatelessWidget {
|
||||||
final Color brandRed;
|
final Color brandRed;
|
||||||
const _RedWaveBackground({required this.brandRed});
|
const _RedWaveBackground({required this.brandRed});
|
||||||
|
|||||||
@ -26,9 +26,8 @@ class WelcomeScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _WelcomeScreenState extends State<WelcomeScreen>
|
class _WelcomeScreenState extends State<WelcomeScreen>
|
||||||
with SingleTickerProviderStateMixin, UIMixin {
|
with SingleTickerProviderStateMixin, UIMixin {
|
||||||
late AnimationController _controller;
|
late final AnimationController _controller;
|
||||||
late Animation<double> _logoAnimation;
|
late final Animation<double> _logoAnimation;
|
||||||
|
|
||||||
bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage");
|
bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage");
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -54,42 +53,39 @@ class _WelcomeScreenState extends State<WelcomeScreen>
|
|||||||
void _showLoginDialog(BuildContext context, LoginOption option) {
|
void _showLoginDialog(BuildContext context, LoginOption option) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false, // Prevent dismiss on outside tap
|
barrierDismissible: false,
|
||||||
builder: (_) => Dialog(
|
builder: (_) => Dialog(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
insetPadding: const EdgeInsets.all(24),
|
insetPadding: const EdgeInsets.all(24),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Padding(
|
padding: const EdgeInsets.all(24),
|
||||||
padding: const EdgeInsets.all(24),
|
child: ConstrainedBox(
|
||||||
child: ConstrainedBox(
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
child: Column(
|
||||||
child: Column(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
children: [
|
Row(
|
||||||
// Row with title and close button
|
children: [
|
||||||
Row(
|
Expanded(
|
||||||
children: [
|
child: MyText(
|
||||||
Expanded(
|
option == LoginOption.email
|
||||||
child: MyText(
|
? "Login with Email"
|
||||||
option == LoginOption.email
|
: "Login with OTP",
|
||||||
? "Login with Email"
|
fontSize: 20,
|
||||||
: "Login with OTP",
|
fontWeight: 700,
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 700,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
IconButton(
|
),
|
||||||
icon: const Icon(Icons.close),
|
IconButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
icon: const Icon(Icons.close),
|
||||||
),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(height: 20),
|
),
|
||||||
option == LoginOption.email
|
const SizedBox(height: 20),
|
||||||
? EmailLoginForm()
|
option == LoginOption.email
|
||||||
: const OTPLoginScreen(),
|
? EmailLoginForm()
|
||||||
],
|
: const OTPLoginScreen(),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -100,6 +96,7 @@ class _WelcomeScreenState extends State<WelcomeScreen>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
final isNarrow = screenWidth < 500;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Stack(
|
body: Stack(
|
||||||
@ -110,72 +107,18 @@ class _WelcomeScreenState extends State<WelcomeScreen>
|
|||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(maxWidth: isNarrow ? double.infinity : 420),
|
||||||
maxWidth: screenWidth < 500 ? double.infinity : 420,
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Logo with circular background
|
_buildLogo(),
|
||||||
ScaleTransition(
|
|
||||||
scale: _logoAnimation,
|
|
||||||
child: Container(
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black12,
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Image.asset(Images.logoDark),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
_buildWelcomeText(),
|
||||||
// Welcome Text
|
|
||||||
MyText(
|
|
||||||
"Welcome to Marco",
|
|
||||||
fontSize: 26,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: Colors.black87,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
MyText(
|
|
||||||
"Streamline Project Management\nBoost Productivity with Automation.",
|
|
||||||
fontSize: 14,
|
|
||||||
color: Colors.black54,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
|
|
||||||
if (_isBetaEnvironment) ...[
|
if (_isBetaEnvironment) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Container(
|
_buildBetaBadge(),
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.orangeAccent,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
child: MyText(
|
|
||||||
'BETA',
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
|
||||||
const SizedBox(height: 36),
|
const SizedBox(height: 36),
|
||||||
|
|
||||||
_buildActionButton(
|
_buildActionButton(
|
||||||
context,
|
context,
|
||||||
label: "Login with Username",
|
label: "Login with Username",
|
||||||
@ -196,7 +139,6 @@ class _WelcomeScreenState extends State<WelcomeScreen>
|
|||||||
icon: LucideIcons.phone_call,
|
icon: LucideIcons.phone_call,
|
||||||
option: null,
|
option: null,
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 36),
|
const SizedBox(height: 36),
|
||||||
MyText(
|
MyText(
|
||||||
'App version 1.0.0',
|
'App version 1.0.0',
|
||||||
@ -214,6 +156,60 @@ class _WelcomeScreenState extends State<WelcomeScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildLogo() {
|
||||||
|
return ScaleTransition(
|
||||||
|
scale: _logoAnimation,
|
||||||
|
child: Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, 4))],
|
||||||
|
),
|
||||||
|
child: Image.asset(Images.logoDark),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWelcomeText() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
MyText(
|
||||||
|
"Welcome to Marco",
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: Colors.black87,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
MyText(
|
||||||
|
"Streamline Project Management\nBoost Productivity with Automation.",
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.black54,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBetaBadge() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orangeAccent,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: MyText(
|
||||||
|
'BETA',
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildActionButton(
|
Widget _buildActionButton(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String label,
|
required String label,
|
||||||
@ -236,9 +232,7 @@ class _WelcomeScreenState extends State<WelcomeScreen>
|
|||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: contentTheme.brandRed,
|
backgroundColor: contentTheme.brandRed,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
),
|
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
shadowColor: Colors.black26,
|
shadowColor: Colors.black26,
|
||||||
),
|
),
|
||||||
@ -254,7 +248,7 @@ class _WelcomeScreenState extends State<WelcomeScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Custom red wave background shifted lower to reduce red area at top
|
// Red wave background painter
|
||||||
class _RedWaveBackground extends StatelessWidget {
|
class _RedWaveBackground extends StatelessWidget {
|
||||||
final Color brandRed;
|
final Color brandRed;
|
||||||
const _RedWaveBackground({required this.brandRed});
|
const _RedWaveBackground({required this.brandRed});
|
||||||
@ -270,7 +264,6 @@ class _RedWaveBackground extends StatelessWidget {
|
|||||||
|
|
||||||
class _WavePainter extends CustomPainter {
|
class _WavePainter extends CustomPainter {
|
||||||
final Color brandRed;
|
final Color brandRed;
|
||||||
|
|
||||||
_WavePainter(this.brandRed);
|
_WavePainter(this.brandRed);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -284,18 +277,8 @@ class _WavePainter extends CustomPainter {
|
|||||||
|
|
||||||
final path1 = Path()
|
final path1 = Path()
|
||||||
..moveTo(0, size.height * 0.2)
|
..moveTo(0, size.height * 0.2)
|
||||||
..quadraticBezierTo(
|
..quadraticBezierTo(size.width * 0.25, size.height * 0.05, size.width * 0.5, size.height * 0.15)
|
||||||
size.width * 0.25,
|
..quadraticBezierTo(size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1)
|
||||||
size.height * 0.05,
|
|
||||||
size.width * 0.5,
|
|
||||||
size.height * 0.15,
|
|
||||||
)
|
|
||||||
..quadraticBezierTo(
|
|
||||||
size.width * 0.75,
|
|
||||||
size.height * 0.25,
|
|
||||||
size.width,
|
|
||||||
size.height * 0.1,
|
|
||||||
)
|
|
||||||
..lineTo(size.width, 0)
|
..lineTo(size.width, 0)
|
||||||
..lineTo(0, 0)
|
..lineTo(0, 0)
|
||||||
..close();
|
..close();
|
||||||
@ -303,15 +286,9 @@ class _WavePainter extends CustomPainter {
|
|||||||
canvas.drawPath(path1, paint1);
|
canvas.drawPath(path1, paint1);
|
||||||
|
|
||||||
final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15);
|
final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15);
|
||||||
|
|
||||||
final path2 = Path()
|
final path2 = Path()
|
||||||
..moveTo(0, size.height * 0.25)
|
..moveTo(0, size.height * 0.25)
|
||||||
..quadraticBezierTo(
|
..quadraticBezierTo(size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2)
|
||||||
size.width * 0.4,
|
|
||||||
size.height * 0.1,
|
|
||||||
size.width,
|
|
||||||
size.height * 0.2,
|
|
||||||
)
|
|
||||||
..lineTo(size.width, 0)
|
..lineTo(size.width, 0)
|
||||||
..lineTo(0, 0)
|
..lineTo(0, 0)
|
||||||
..close();
|
..close();
|
||||||
|
|||||||
@ -139,6 +139,7 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
|
|||||||
Widget _buildMPINCard(MPINController controller) {
|
Widget _buildMPINCard(MPINController controller) {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
final isNewUser = controller.isNewUser.value;
|
final isNewUser = controller.isNewUser.value;
|
||||||
|
final isChangeMpin = controller.isChangeMpin.value;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
@ -156,7 +157,9 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
MyText(
|
MyText(
|
||||||
isNewUser ? 'Generate MPIN' : 'Enter MPIN',
|
isChangeMpin
|
||||||
|
? 'Change MPIN'
|
||||||
|
: (isNewUser ? 'Generate MPIN' : 'Enter MPIN'),
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
@ -164,17 +167,19 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
MyText(
|
MyText(
|
||||||
isNewUser
|
isChangeMpin
|
||||||
? 'Set your 6-digit MPIN for quick login.'
|
? 'Set a new 4-digit MPIN for your account.'
|
||||||
: 'Enter your 6-digit MPIN to continue.',
|
: (isNewUser
|
||||||
|
? 'Set your 4-digit MPIN for quick login.'
|
||||||
|
: 'Enter your 4-digit MPIN to continue.'),
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: Colors.black54,
|
color: Colors.black54,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
_buildMPINForm(controller, isNewUser),
|
_buildMPINForm(controller, isNewUser || isChangeMpin),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
_buildSubmitButton(controller, isNewUser),
|
_buildSubmitButton(controller, isNewUser, isChangeMpin),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildFooterOptions(controller, isNewUser),
|
_buildFooterOptions(controller, isNewUser),
|
||||||
],
|
],
|
||||||
@ -183,13 +188,13 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMPINForm(MPINController controller, bool isNewUser) {
|
Widget _buildMPINForm(MPINController controller, bool showRetype) {
|
||||||
return Form(
|
return Form(
|
||||||
key: controller.formKey,
|
key: controller.formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildDigitRow(controller, isRetype: false),
|
_buildDigitRow(controller, isRetype: false),
|
||||||
if (isNewUser) ...[
|
if (showRetype) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
MyText(
|
MyText(
|
||||||
'Retype MPIN',
|
'Retype MPIN',
|
||||||
@ -206,11 +211,9 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDigitRow(MPINController controller, {required bool isRetype}) {
|
Widget _buildDigitRow(MPINController controller, {required bool isRetype}) {
|
||||||
return Wrap(
|
return Row(
|
||||||
alignment: WrapAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
spacing: 0,
|
children: List.generate(4, (index) {
|
||||||
runSpacing: 12,
|
|
||||||
children: List.generate(6, (index) {
|
|
||||||
return _buildDigitBox(controller, index, isRetype);
|
return _buildDigitBox(controller, index, isRetype);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -225,29 +228,33 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
|
|||||||
: controller.focusNodes[index];
|
: controller.focusNodes[index];
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
width: 30,
|
width: 48,
|
||||||
height: 55,
|
height: 60,
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: textController,
|
controller: textController,
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
obscureText: true,
|
obscureText: false, // Digits are visible
|
||||||
maxLength: 1,
|
maxLength: 1,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
letterSpacing: 8,
|
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
controller.onDigitChanged(value, index, isRetype: isRetype);
|
controller.onDigitChanged(value, index, isRetype: isRetype);
|
||||||
|
|
||||||
|
// Auto-submit only in verification mode
|
||||||
if (!isRetype) {
|
if (!isRetype) {
|
||||||
final isComplete =
|
final isComplete =
|
||||||
controller.digitControllers.every((c) => c.text.isNotEmpty);
|
controller.digitControllers.every((c) => c.text.isNotEmpty);
|
||||||
if (isComplete && !controller.isLoading.value) {
|
|
||||||
|
if (isComplete &&
|
||||||
|
!controller.isLoading.value &&
|
||||||
|
!controller.isNewUser.value &&
|
||||||
|
!controller.isChangeMpin.value) {
|
||||||
controller.onSubmitMPIN();
|
controller.onSubmitMPIN();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -265,7 +272,8 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSubmitButton(MPINController controller, bool isNewUser) {
|
Widget _buildSubmitButton(
|
||||||
|
MPINController controller, bool isNewUser, bool isChangeMpin) {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
return MyButton.rounded(
|
return MyButton.rounded(
|
||||||
onPressed: controller.isLoading.value ? null : controller.onSubmitMPIN,
|
onPressed: controller.isLoading.value ? null : controller.onSubmitMPIN,
|
||||||
@ -285,7 +293,9 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: MyText.bodyMedium(
|
: MyText.bodyMedium(
|
||||||
isNewUser ? 'Generate MPIN' : 'Submit MPIN',
|
isChangeMpin
|
||||||
|
? 'Change MPIN'
|
||||||
|
: (isNewUser ? 'Generate MPIN' : 'Submit MPIN'),
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@ -296,12 +306,13 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
|
|||||||
|
|
||||||
Widget _buildFooterOptions(MPINController controller, bool isNewUser) {
|
Widget _buildFooterOptions(MPINController controller, bool isNewUser) {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
|
final isChangeMpin = controller.isChangeMpin.value;
|
||||||
final showBackToLogin =
|
final showBackToLogin =
|
||||||
controller.failedAttempts.value >= 3 && !isNewUser;
|
controller.failedAttempts.value >= 3 && !isNewUser && !isChangeMpin;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
if (isNewUser)
|
if (isNewUser || isChangeMpin)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () => Get.toNamed('/dashboard'),
|
onPressed: () => Get.toNamed('/dashboard'),
|
||||||
icon: const Icon(Icons.arrow_back,
|
icon: const Icon(Icons.arrow_back,
|
||||||
@ -359,8 +370,8 @@ class _WavePainter extends CustomPainter {
|
|||||||
|
|
||||||
final path1 = Path()
|
final path1 = Path()
|
||||||
..moveTo(0, size.height * 0.2)
|
..moveTo(0, size.height * 0.2)
|
||||||
..quadraticBezierTo(
|
..quadraticBezierTo(size.width * 0.25, size.height * 0.05,
|
||||||
size.width * 0.25, size.height * 0.05, size.width * 0.5, size.height * 0.15)
|
size.width * 0.5, size.height * 0.15)
|
||||||
..quadraticBezierTo(
|
..quadraticBezierTo(
|
||||||
size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1)
|
size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1)
|
||||||
..lineTo(size.width, 0)
|
..lineTo(size.width, 0)
|
||||||
|
|||||||
189
lib/view/dashboard/Attendence/attendance_logs_tab.dart
Normal file
189
lib/view/dashboard/Attendence/attendance_logs_tab.dart
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
// lib/view/attendance/tabs/attendance_logs_tab.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:marco/controller/dashboard/attendance_screen_controller.dart';
|
||||||
|
import 'package:marco/helpers/widgets/avatar.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_card.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_container.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||||
|
import 'package:marco/model/attendance/log_details_view.dart';
|
||||||
|
import 'package:marco/model/attendance/attendence_action_button.dart';
|
||||||
|
|
||||||
|
class AttendanceLogsTab extends StatelessWidget {
|
||||||
|
final AttendanceController controller;
|
||||||
|
|
||||||
|
const AttendanceLogsTab({super.key, required this.controller});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(() {
|
||||||
|
final logs = List.of(controller.attendanceLogs);
|
||||||
|
logs.sort((a, b) {
|
||||||
|
final aDate = a.checkIn ?? DateTime(0);
|
||||||
|
final bDate = b.checkIn ?? DateTime(0);
|
||||||
|
return bDate.compareTo(aDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
final dateFormat = DateFormat('dd MMM yyyy');
|
||||||
|
final dateRangeText = controller.startDateAttendance != null &&
|
||||||
|
controller.endDateAttendance != null
|
||||||
|
? '${dateFormat.format(controller.endDateAttendance!)} - ${dateFormat.format(controller.startDateAttendance!)}'
|
||||||
|
: 'Select date range';
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
MyText.titleMedium("Attendance Logs", fontWeight: 600),
|
||||||
|
controller.isLoading.value
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20, width: 20, child: LinearProgressIndicator())
|
||||||
|
: MyText.bodySmall(
|
||||||
|
dateRangeText,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (controller.isLoadingAttendanceLogs.value)
|
||||||
|
SkeletonLoaders.employeeListSkeletonLoader()
|
||||||
|
else if (logs.isEmpty)
|
||||||
|
const SizedBox(
|
||||||
|
height: 120,
|
||||||
|
child: Center(
|
||||||
|
child: Text("No Attendance Logs Found for this Project"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
MyCard.bordered(
|
||||||
|
paddingAll: 8,
|
||||||
|
child: Column(
|
||||||
|
children: List.generate(logs.length, (index) {
|
||||||
|
final employee = logs[index];
|
||||||
|
final currentDate = employee.checkIn != null
|
||||||
|
? DateFormat('dd MMM yyyy').format(employee.checkIn!)
|
||||||
|
: '';
|
||||||
|
final previousDate =
|
||||||
|
index > 0 && logs[index - 1].checkIn != null
|
||||||
|
? DateFormat('dd MMM yyyy')
|
||||||
|
.format(logs[index - 1].checkIn!)
|
||||||
|
: '';
|
||||||
|
final showDateHeader =
|
||||||
|
index == 0 || currentDate != previousDate;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (showDateHeader)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: MyText.bodyMedium(
|
||||||
|
currentDate,
|
||||||
|
fontWeight: 700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MyContainer(
|
||||||
|
paddingAll: 8,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Avatar(
|
||||||
|
firstName: employee.firstName,
|
||||||
|
lastName: employee.lastName,
|
||||||
|
size: 31,
|
||||||
|
),
|
||||||
|
MySpacing.width(16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: MyText.bodyMedium(
|
||||||
|
employee.name,
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.width(6),
|
||||||
|
Flexible(
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
'(${employee.designation})',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
if (employee.checkIn != null ||
|
||||||
|
employee.checkOut != null)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (employee.checkIn != null) ...[
|
||||||
|
const Icon(Icons.arrow_circle_right,
|
||||||
|
size: 16, color: Colors.green),
|
||||||
|
MySpacing.width(4),
|
||||||
|
MyText.bodySmall(
|
||||||
|
DateFormat('hh:mm a')
|
||||||
|
.format(employee.checkIn!),
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
MySpacing.width(16),
|
||||||
|
],
|
||||||
|
if (employee.checkOut != null) ...[
|
||||||
|
const Icon(Icons.arrow_circle_left,
|
||||||
|
size: 16, color: Colors.red),
|
||||||
|
MySpacing.width(4),
|
||||||
|
MyText.bodySmall(
|
||||||
|
DateFormat('hh:mm a')
|
||||||
|
.format(employee.checkOut!),
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
AttendanceActionButton(
|
||||||
|
employee: employee,
|
||||||
|
attendanceController: controller,
|
||||||
|
),
|
||||||
|
MySpacing.width(8),
|
||||||
|
AttendanceLogViewButton(
|
||||||
|
employee: employee,
|
||||||
|
attendanceController: controller,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (index != logs.length - 1)
|
||||||
|
Divider(color: Colors.grey.withOpacity(0.3)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
157
lib/view/dashboard/Attendence/regularization_requests_tab.dart
Normal file
157
lib/view/dashboard/Attendence/regularization_requests_tab.dart
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
// lib/view/attendance/tabs/regularization_requests_tab.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:marco/controller/dashboard/attendance_screen_controller.dart';
|
||||||
|
import 'package:marco/helpers/widgets/avatar.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_card.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_container.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||||
|
import 'package:marco/model/attendance/log_details_view.dart';
|
||||||
|
import 'package:marco/model/attendance/regualrize_action_button.dart';
|
||||||
|
|
||||||
|
class RegularizationRequestsTab extends StatelessWidget {
|
||||||
|
final AttendanceController controller;
|
||||||
|
|
||||||
|
const RegularizationRequestsTab({super.key, required this.controller});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 4.0),
|
||||||
|
child: MyText.titleMedium("Regularization Requests", fontWeight: 600),
|
||||||
|
),
|
||||||
|
Obx(() {
|
||||||
|
final employees = controller.regularizationLogs;
|
||||||
|
|
||||||
|
if (controller.isLoadingRegularizationLogs.value) {
|
||||||
|
return SkeletonLoaders.employeeListSkeletonLoader();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (employees.isEmpty) {
|
||||||
|
return const SizedBox(
|
||||||
|
height: 120,
|
||||||
|
child: Center(
|
||||||
|
child: Text("No Regularization Requests Found for this Project"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return MyCard.bordered(
|
||||||
|
paddingAll: 8,
|
||||||
|
child: Column(
|
||||||
|
children: List.generate(employees.length, (index) {
|
||||||
|
final employee = employees[index];
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
MyContainer(
|
||||||
|
paddingAll: 8,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Avatar(
|
||||||
|
firstName: employee.firstName,
|
||||||
|
lastName: employee.lastName,
|
||||||
|
size: 31,
|
||||||
|
),
|
||||||
|
MySpacing.width(16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: MyText.bodyMedium(
|
||||||
|
employee.name,
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.width(6),
|
||||||
|
Flexible(
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
'(${employee.role})',
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
if (employee.checkIn != null ||
|
||||||
|
employee.checkOut != null)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (employee.checkIn != null) ...[
|
||||||
|
const Icon(Icons.arrow_circle_right,
|
||||||
|
size: 16, color: Colors.green),
|
||||||
|
MySpacing.width(4),
|
||||||
|
MyText.bodySmall(
|
||||||
|
DateFormat('hh:mm a')
|
||||||
|
.format(employee.checkIn!),
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
MySpacing.width(16),
|
||||||
|
],
|
||||||
|
if (employee.checkOut != null) ...[
|
||||||
|
const Icon(Icons.arrow_circle_left,
|
||||||
|
size: 16, color: Colors.red),
|
||||||
|
MySpacing.width(4),
|
||||||
|
MyText.bodySmall(
|
||||||
|
DateFormat('hh:mm a')
|
||||||
|
.format(employee.checkOut!),
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
RegularizeActionButton(
|
||||||
|
attendanceController: controller,
|
||||||
|
log: employee,
|
||||||
|
uniqueLogKey: employee.employeeId,
|
||||||
|
action: ButtonActions.approve,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
RegularizeActionButton(
|
||||||
|
attendanceController: controller,
|
||||||
|
log: employee,
|
||||||
|
uniqueLogKey: employee.employeeId,
|
||||||
|
action: ButtonActions.reject,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (employee.checkIn != null)
|
||||||
|
AttendanceLogViewButton(
|
||||||
|
employee: employee,
|
||||||
|
attendanceController: controller,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (index != employees.length - 1)
|
||||||
|
Divider(color: Colors.grey.withOpacity(0.3)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
lib/view/dashboard/Attendence/todays_attendance_tab.dart
Normal file
131
lib/view/dashboard/Attendence/todays_attendance_tab.dart
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:marco/controller/dashboard/attendance_screen_controller.dart';
|
||||||
|
import 'package:marco/helpers/widgets/avatar.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_card.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_container.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||||
|
import 'package:marco/model/attendance/log_details_view.dart';
|
||||||
|
import 'package:marco/model/attendance/attendence_action_button.dart';
|
||||||
|
|
||||||
|
class TodaysAttendanceTab extends StatelessWidget {
|
||||||
|
final AttendanceController controller;
|
||||||
|
|
||||||
|
const TodaysAttendanceTab({super.key, required this.controller});
|
||||||
|
|
||||||
|
String _formatDate(DateTime date) {
|
||||||
|
return "${date.day}/${date.month}/${date.year}";
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(() {
|
||||||
|
final isLoading = controller.isLoadingEmployees.value;
|
||||||
|
final employees = controller.employees;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: MyText.titleMedium("Today's Attendance", fontWeight: 600),
|
||||||
|
),
|
||||||
|
MyText.bodySmall(
|
||||||
|
_formatDate(DateTime.now()),
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isLoading)
|
||||||
|
SkeletonLoaders.employeeListSkeletonLoader()
|
||||||
|
else if (employees.isEmpty)
|
||||||
|
const SizedBox(height: 120, child: Center(child: Text("No Employees Assigned")))
|
||||||
|
else
|
||||||
|
MyCard.bordered(
|
||||||
|
paddingAll: 8,
|
||||||
|
child: Column(
|
||||||
|
children: List.generate(employees.length, (index) {
|
||||||
|
final employee = employees[index];
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
MyContainer(
|
||||||
|
paddingAll: 5,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Avatar(firstName: employee.firstName, lastName: employee.lastName, size: 31),
|
||||||
|
MySpacing.width(16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
children: [
|
||||||
|
MyText.bodyMedium(employee.name, fontWeight: 600),
|
||||||
|
MyText.bodySmall('(${employee.designation})', fontWeight: 600, color: Colors.grey[700]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
if (employee.checkIn != null || employee.checkOut != null)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (employee.checkIn != null)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.arrow_circle_right, size: 16, color: Colors.green),
|
||||||
|
MySpacing.width(4),
|
||||||
|
Text(DateFormat('hh:mm a').format(employee.checkIn!)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (employee.checkOut != null) ...[
|
||||||
|
MySpacing.width(16),
|
||||||
|
const Icon(Icons.arrow_circle_left, size: 16, color: Colors.red),
|
||||||
|
MySpacing.width(4),
|
||||||
|
Text(DateFormat('hh:mm a').format(employee.checkOut!)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
AttendanceActionButton(
|
||||||
|
employee: employee,
|
||||||
|
attendanceController: controller,
|
||||||
|
),
|
||||||
|
if (employee.checkIn != null) ...[
|
||||||
|
MySpacing.width(8),
|
||||||
|
AttendanceLogViewButton(
|
||||||
|
employee: employee,
|
||||||
|
attendanceController: controller,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (index != employees.length - 1)
|
||||||
|
Divider(color: Colors.grey.withOpacity(0.3)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -36,6 +36,11 @@ class AttendanceDashboardChart extends StatelessWidget {
|
|||||||
List<String> get filteredRoles =>
|
List<String> get filteredRoles =>
|
||||||
filteredData.map((e) => e['role'] as String).toSet().toList();
|
filteredData.map((e) => e['role'] as String).toSet().toList();
|
||||||
|
|
||||||
|
List<String> get rolesWithData => filteredRoles.where((role) {
|
||||||
|
return filteredData.any(
|
||||||
|
(entry) => entry['role'] == role && (entry['present'] ?? 0) > 0);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
final Map<String, Color> _roleColorMap = {};
|
final Map<String, Color> _roleColorMap = {};
|
||||||
final List<Color> flatColors = [
|
final List<Color> flatColors = [
|
||||||
const Color(0xFFE57373),
|
const Color(0xFFE57373),
|
||||||
@ -52,7 +57,6 @@ class AttendanceDashboardChart extends StatelessWidget {
|
|||||||
|
|
||||||
Color _getRoleColor(String role) {
|
Color _getRoleColor(String role) {
|
||||||
if (_roleColorMap.containsKey(role)) return _roleColorMap[role]!;
|
if (_roleColorMap.containsKey(role)) return _roleColorMap[role]!;
|
||||||
|
|
||||||
final index = _roleColorMap.length % flatColors.length;
|
final index = _roleColorMap.length % flatColors.length;
|
||||||
final color = flatColors[index];
|
final color = flatColors[index];
|
||||||
_roleColorMap[role] = color;
|
_roleColorMap[role] = color;
|
||||||
@ -62,7 +66,6 @@ class AttendanceDashboardChart extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
// Now this observes `controller.roleWiseData`, `isLoading`, `isChartView`, etc.
|
|
||||||
final isChartView = controller.isChartView.value;
|
final isChartView = controller.isChartView.value;
|
||||||
final selectedRange = controller.selectedRange.value;
|
final selectedRange = controller.selectedRange.value;
|
||||||
final isLoading = controller.isLoading.value;
|
final isLoading = controller.isLoading.value;
|
||||||
@ -78,8 +81,7 @@ class AttendanceDashboardChart extends StatelessWidget {
|
|||||||
child: Card(
|
child: Card(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
elevation: 6,
|
elevation: 6,
|
||||||
shape:
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
||||||
shadowColor: Colors.black12,
|
shadowColor: Colors.black12,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
@ -116,10 +118,7 @@ class AttendanceDashboardChart extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
MyText.bodyMedium('Attendance Overview', fontWeight: 600),
|
MyText.bodyMedium('Attendance Overview', fontWeight: 600),
|
||||||
MyText.bodySmall(
|
MyText.bodySmall('Role-wise present count', color: Colors.grey),
|
||||||
'Role-wise present count',
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -213,7 +212,7 @@ class AttendanceDashboardChart extends StatelessWidget {
|
|||||||
interval: 1,
|
interval: 1,
|
||||||
majorGridLines: const MajorGridLines(width: 0),
|
majorGridLines: const MajorGridLines(width: 0),
|
||||||
),
|
),
|
||||||
series: filteredRoles.map((role) {
|
series: rolesWithData.map((role) {
|
||||||
final data = filteredDates.map((formattedDate) {
|
final data = filteredDates.map((formattedDate) {
|
||||||
final key = '${role}_$formattedDate';
|
final key = '${role}_$formattedDate';
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import 'package:marco/controller/project_controller.dart';
|
|||||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||||
import 'package:marco/helpers/utils/my_shadow.dart';
|
import 'package:marco/helpers/utils/my_shadow.dart';
|
||||||
import 'package:marco/helpers/widgets/my_button.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_card.dart';
|
import 'package:marco/helpers/widgets/my_card.dart';
|
||||||
import 'package:marco/helpers/widgets/my_container.dart';
|
import 'package:marco/helpers/widgets/my_container.dart';
|
||||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
@ -23,9 +22,10 @@ class DashboardScreen extends StatefulWidget {
|
|||||||
static const String attendanceRoute = "/dashboard/attendance";
|
static const String attendanceRoute = "/dashboard/attendance";
|
||||||
static const String tasksRoute = "/dashboard/daily-task";
|
static const String tasksRoute = "/dashboard/daily-task";
|
||||||
static const String dailyTasksRoute = "/dashboard/daily-task-planing";
|
static const String dailyTasksRoute = "/dashboard/daily-task-planing";
|
||||||
static const String dailyTasksProgressRoute =
|
static const String dailyTasksProgressRoute = "/dashboard/daily-task-progress";
|
||||||
"/dashboard/daily-task-progress";
|
|
||||||
static const String directoryMainPageRoute = "/dashboard/directory-main-page";
|
static const String directoryMainPageRoute = "/dashboard/directory-main-page";
|
||||||
|
static const String expenseMainPageRoute = "/dashboard/expense-main-page";
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DashboardScreen> createState() => _DashboardScreenState();
|
State<DashboardScreen> createState() => _DashboardScreenState();
|
||||||
@ -53,11 +53,10 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Layout(
|
return Layout(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(10),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
MySpacing.height(12),
|
|
||||||
_buildDashboardStats(),
|
_buildDashboardStats(),
|
||||||
MySpacing.height(24),
|
MySpacing.height(24),
|
||||||
GetBuilder<ProjectController>(
|
GetBuilder<ProjectController>(
|
||||||
@ -74,71 +73,12 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
AttendanceDashboardChart(),
|
AttendanceDashboardChart(),
|
||||||
MySpacing.height(300),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (!hasMpin) ...[
|
|
||||||
MyCard(
|
|
||||||
borderRadiusAll: 12,
|
|
||||||
paddingAll: 16,
|
|
||||||
shadow: MyShadow(elevation: 2),
|
|
||||||
color: Colors.red.withOpacity(0.05),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.warning_amber_rounded,
|
|
||||||
color: Colors.redAccent, size: 28),
|
|
||||||
MySpacing.width(12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MyText.bodyMedium(
|
|
||||||
"MPIN Not Generated",
|
|
||||||
color: Colors.redAccent,
|
|
||||||
fontWeight: 700,
|
|
||||||
),
|
|
||||||
MySpacing.height(4),
|
|
||||||
MyText.bodySmall(
|
|
||||||
"To secure your account, please generate your MPIN now.",
|
|
||||||
color: contentTheme.onBackground.withOpacity(0.8),
|
|
||||||
),
|
|
||||||
MySpacing.height(10),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: MyButton.rounded(
|
|
||||||
onPressed: () {
|
|
||||||
Get.toNamed("/auth/mpin-auth");
|
|
||||||
},
|
|
||||||
backgroundColor: contentTheme.brandRed,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 20, vertical: 10),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.lock_outline,
|
|
||||||
size: 18, color: Colors.white),
|
|
||||||
MySpacing.width(8),
|
|
||||||
MyText.bodyMedium(
|
|
||||||
"Generate MPIN",
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -157,6 +97,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
DashboardScreen.dailyTasksProgressRoute),
|
DashboardScreen.dailyTasksProgressRoute),
|
||||||
_StatItem(LucideIcons.folder, "Directory", contentTheme.info,
|
_StatItem(LucideIcons.folder, "Directory", contentTheme.info,
|
||||||
DashboardScreen.directoryMainPageRoute),
|
DashboardScreen.directoryMainPageRoute),
|
||||||
|
_StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info,
|
||||||
|
DashboardScreen.expenseMainPageRoute),
|
||||||
];
|
];
|
||||||
|
|
||||||
return GetBuilder<ProjectController>(
|
return GetBuilder<ProjectController>(
|
||||||
|
|||||||
@ -16,32 +16,20 @@ import 'package:marco/model/directory/add_comment_bottom_sheet.dart';
|
|||||||
import 'package:marco/helpers/utils/date_time_utils.dart';
|
import 'package:marco/helpers/utils/date_time_utils.dart';
|
||||||
import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
|
import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
|
||||||
|
|
||||||
class ContactDetailScreen extends StatefulWidget {
|
// HELPER: Delta to HTML conversion
|
||||||
final ContactModel contact;
|
|
||||||
|
|
||||||
const ContactDetailScreen({super.key, required this.contact});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ContactDetailScreen> createState() => _ContactDetailScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _convertDeltaToHtml(dynamic delta) {
|
String _convertDeltaToHtml(dynamic delta) {
|
||||||
final buffer = StringBuffer();
|
final buffer = StringBuffer();
|
||||||
bool inList = false;
|
bool inList = false;
|
||||||
|
|
||||||
for (var op in delta.toList()) {
|
for (var op in delta.toList()) {
|
||||||
final data = op.data?.toString() ?? '';
|
final String data = op.data?.toString() ?? '';
|
||||||
final attr = op.attributes ?? {};
|
final attr = op.attributes ?? {};
|
||||||
|
final bool isListItem = attr.containsKey('list');
|
||||||
|
|
||||||
final isListItem = attr.containsKey('list');
|
|
||||||
|
|
||||||
// Start list
|
|
||||||
if (isListItem && !inList) {
|
if (isListItem && !inList) {
|
||||||
buffer.write('<ul>');
|
buffer.write('<ul>');
|
||||||
inList = true;
|
inList = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close list if we are not in list mode anymore
|
|
||||||
if (!isListItem && inList) {
|
if (!isListItem && inList) {
|
||||||
buffer.write('</ul>');
|
buffer.write('</ul>');
|
||||||
inList = false;
|
inList = false;
|
||||||
@ -49,15 +37,12 @@ String _convertDeltaToHtml(dynamic delta) {
|
|||||||
|
|
||||||
if (isListItem) buffer.write('<li>');
|
if (isListItem) buffer.write('<li>');
|
||||||
|
|
||||||
// Apply inline styles
|
|
||||||
if (attr.containsKey('bold')) buffer.write('<strong>');
|
if (attr.containsKey('bold')) buffer.write('<strong>');
|
||||||
if (attr.containsKey('italic')) buffer.write('<em>');
|
if (attr.containsKey('italic')) buffer.write('<em>');
|
||||||
if (attr.containsKey('underline')) buffer.write('<u>');
|
if (attr.containsKey('underline')) buffer.write('<u>');
|
||||||
if (attr.containsKey('strike')) buffer.write('<s>');
|
if (attr.containsKey('strike')) buffer.write('<s>');
|
||||||
if (attr.containsKey('link')) buffer.write('<a href="${attr['link']}">');
|
if (attr.containsKey('link')) buffer.write('<a href="${attr['link']}">');
|
||||||
|
|
||||||
buffer.write(data.replaceAll('\n', ''));
|
buffer.write(data.replaceAll('\n', ''));
|
||||||
|
|
||||||
if (attr.containsKey('link')) buffer.write('</a>');
|
if (attr.containsKey('link')) buffer.write('</a>');
|
||||||
if (attr.containsKey('strike')) buffer.write('</s>');
|
if (attr.containsKey('strike')) buffer.write('</s>');
|
||||||
if (attr.containsKey('underline')) buffer.write('</u>');
|
if (attr.containsKey('underline')) buffer.write('</u>');
|
||||||
@ -66,14 +51,21 @@ String _convertDeltaToHtml(dynamic delta) {
|
|||||||
|
|
||||||
if (isListItem)
|
if (isListItem)
|
||||||
buffer.write('</li>');
|
buffer.write('</li>');
|
||||||
else if (data.contains('\n')) buffer.write('<br>');
|
else if (data.contains('\n')) {
|
||||||
|
buffer.write('<br>');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inList) buffer.write('</ul>');
|
if (inList) buffer.write('</ul>');
|
||||||
|
|
||||||
return buffer.toString();
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ContactDetailScreen extends StatefulWidget {
|
||||||
|
final ContactModel contact;
|
||||||
|
const ContactDetailScreen({super.key, required this.contact});
|
||||||
|
@override
|
||||||
|
State<ContactDetailScreen> createState() => _ContactDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
||||||
late final DirectoryController directoryController;
|
late final DirectoryController directoryController;
|
||||||
late final ProjectController projectController;
|
late final ProjectController projectController;
|
||||||
@ -85,7 +77,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
directoryController = Get.find<DirectoryController>();
|
directoryController = Get.find<DirectoryController>();
|
||||||
projectController = Get.find<ProjectController>();
|
projectController = Get.find<ProjectController>();
|
||||||
contact = widget.contact;
|
contact = widget.contact;
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
directoryController.fetchCommentsForContact(contact.id);
|
directoryController.fetchCommentsForContact(contact.id);
|
||||||
});
|
});
|
||||||
@ -103,13 +94,12 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildSubHeader(),
|
_buildSubHeader(),
|
||||||
|
const Divider(height: 1, thickness: 0.5, color: Colors.grey),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TabBarView(children: [
|
||||||
children: [
|
_buildDetailsTab(),
|
||||||
_buildDetailsTab(),
|
_buildCommentsTab(context),
|
||||||
_buildCommentsTab(context),
|
]),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -130,10 +120,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_ios_new,
|
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20),
|
||||||
color: Colors.black, size: 20),
|
onPressed: () => Get.offAllNamed('/dashboard/directory-main-page'),
|
||||||
onPressed: () =>
|
|
||||||
Get.offAllNamed('/dashboard/directory-main-page'),
|
|
||||||
),
|
),
|
||||||
MySpacing.width(8),
|
MySpacing.width(8),
|
||||||
Expanded(
|
Expanded(
|
||||||
@ -141,30 +129,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
MyText.titleLarge('Contact Profile',
|
MyText.titleLarge('Contact Profile', fontWeight: 700, color: Colors.black),
|
||||||
fontWeight: 700, color: Colors.black),
|
|
||||||
MySpacing.height(2),
|
MySpacing.height(2),
|
||||||
GetBuilder<ProjectController>(
|
GetBuilder<ProjectController>(
|
||||||
builder: (projectController) {
|
builder: (p) => ProjectLabel(p.selectedProject?.name),
|
||||||
final projectName =
|
|
||||||
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(
|
|
||||||
projectName,
|
|
||||||
fontWeight: 600,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
color: Colors.grey[700],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -176,38 +144,30 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSubHeader() {
|
Widget _buildSubHeader() {
|
||||||
|
final firstName = contact.name.split(" ").first;
|
||||||
|
final lastName = contact.name.split(" ").length > 1 ? contact.name.split(" ").last : "";
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: MySpacing.xy(16, 12),
|
padding: MySpacing.xy(16, 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(children: [
|
||||||
children: [
|
Avatar(firstName: firstName, lastName: lastName, size: 35, backgroundColor: Colors.indigo),
|
||||||
Avatar(
|
MySpacing.width(12),
|
||||||
firstName: contact.name.split(" ").first,
|
Column(
|
||||||
lastName: contact.name.split(" ").length > 1
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
? contact.name.split(" ").last
|
children: [
|
||||||
: "",
|
MyText.titleSmall(contact.name, fontWeight: 600, color: Colors.black),
|
||||||
size: 35,
|
MySpacing.height(2),
|
||||||
backgroundColor: Colors.indigo,
|
MyText.bodySmall(contact.organization, fontWeight: 500, color: Colors.grey[700]),
|
||||||
),
|
],
|
||||||
MySpacing.width(12),
|
),
|
||||||
Column(
|
]),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MyText.titleSmall(contact.name,
|
|
||||||
fontWeight: 600, color: Colors.black),
|
|
||||||
MySpacing.height(2),
|
|
||||||
MyText.bodySmall(contact.organization,
|
|
||||||
fontWeight: 500, color: Colors.grey[700]),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
TabBar(
|
TabBar(
|
||||||
labelColor: Colors.red,
|
labelColor: Colors.red,
|
||||||
unselectedLabelColor: Colors.black,
|
unselectedLabelColor: Colors.black,
|
||||||
indicator: MaterialIndicator(
|
indicator: MaterialIndicator(
|
||||||
color: Colors.red,
|
color: Colors.red,
|
||||||
height: 4,
|
height: 4,
|
||||||
topLeftRadius: 8,
|
topLeftRadius: 8,
|
||||||
@ -226,33 +186,37 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDetailsTab() {
|
Widget _buildDetailsTab() {
|
||||||
final email = contact.contactEmails.isNotEmpty
|
|
||||||
? contact.contactEmails.first.emailAddress
|
|
||||||
: "-";
|
|
||||||
|
|
||||||
final phone = contact.contactPhones.isNotEmpty
|
|
||||||
? contact.contactPhones.first.phoneNumber
|
|
||||||
: "-";
|
|
||||||
|
|
||||||
final tags = contact.tags.map((e) => e.name).join(", ");
|
final tags = contact.tags.map((e) => e.name).join(", ");
|
||||||
|
|
||||||
final bucketNames = contact.bucketIds
|
final bucketNames = contact.bucketIds
|
||||||
.map((id) => directoryController.contactBuckets
|
.map((id) => directoryController.contactBuckets
|
||||||
.firstWhereOrNull((b) => b.id == id)
|
.firstWhereOrNull((b) => b.id == id)
|
||||||
?.name)
|
?.name)
|
||||||
.whereType<String>()
|
.whereType<String>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
final projectNames = contact.projectIds?.map((id) =>
|
||||||
final projectNames = contact.projectIds
|
projectController.projects.firstWhereOrNull((p) => p.id == id)?.name).whereType<String>().join(", ") ?? "-";
|
||||||
?.map((id) => projectController.projects
|
|
||||||
.firstWhereOrNull((p) => p.id == id)
|
|
||||||
?.name)
|
|
||||||
.whereType<String>()
|
|
||||||
.join(", ") ??
|
|
||||||
"-";
|
|
||||||
|
|
||||||
final category = contact.contactCategory?.name ?? "-";
|
final category = contact.contactCategory?.name ?? "-";
|
||||||
|
|
||||||
|
Widget multiRows({required List<dynamic> items, required IconData icon, required String label, required String typeLabel, required Function(String)? onTap, required Function(String)? onLongPress}) {
|
||||||
|
return items.isNotEmpty
|
||||||
|
? Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_iconInfoRow(icon, label, items.first, onTap: () => onTap?.call(items.first), onLongPress: () => onLongPress?.call(items.first)),
|
||||||
|
...items.skip(1).map(
|
||||||
|
(val) => _iconInfoRow(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
val,
|
||||||
|
onTap: () => onTap?.call(val),
|
||||||
|
onLongPress: () => onLongPress?.call(val),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: _iconInfoRow(icon, label, "-");
|
||||||
|
}
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
SingleChildScrollView(
|
SingleChildScrollView(
|
||||||
@ -261,28 +225,38 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
MySpacing.height(12),
|
MySpacing.height(12),
|
||||||
|
// BASIC INFO CARD
|
||||||
_infoCard("Basic Info", [
|
_infoCard("Basic Info", [
|
||||||
_iconInfoRow(Icons.email, "Email", email,
|
multiRows(
|
||||||
onTap: () => LauncherUtils.launchEmail(email),
|
items: contact.contactEmails.map((e) => e.emailAddress).toList(),
|
||||||
onLongPress: () => LauncherUtils.copyToClipboard(email,
|
icon: Icons.email,
|
||||||
typeLabel: "Email")),
|
label: "Email",
|
||||||
_iconInfoRow(Icons.phone, "Phone", phone,
|
typeLabel: "Email",
|
||||||
onTap: () => LauncherUtils.launchPhone(phone),
|
onTap: (email) => LauncherUtils.launchEmail(email),
|
||||||
onLongPress: () => LauncherUtils.copyToClipboard(phone,
|
onLongPress: (email) => LauncherUtils.copyToClipboard(email, typeLabel: "Email"),
|
||||||
typeLabel: "Phone")),
|
),
|
||||||
|
multiRows(
|
||||||
|
items: contact.contactPhones.map((p) => p.phoneNumber).toList(),
|
||||||
|
icon: Icons.phone,
|
||||||
|
label: "Phone",
|
||||||
|
typeLabel: "Phone",
|
||||||
|
onTap: (phone) => LauncherUtils.launchPhone(phone),
|
||||||
|
onLongPress: (phone) => LauncherUtils.copyToClipboard(phone, typeLabel: "Phone"),
|
||||||
|
),
|
||||||
_iconInfoRow(Icons.location_on, "Address", contact.address),
|
_iconInfoRow(Icons.location_on, "Address", contact.address),
|
||||||
]),
|
]),
|
||||||
|
// ORGANIZATION CARD
|
||||||
_infoCard("Organization", [
|
_infoCard("Organization", [
|
||||||
_iconInfoRow(
|
_iconInfoRow(Icons.business, "Organization", contact.organization),
|
||||||
Icons.business, "Organization", contact.organization),
|
|
||||||
_iconInfoRow(Icons.category, "Category", category),
|
_iconInfoRow(Icons.category, "Category", category),
|
||||||
]),
|
]),
|
||||||
|
// META INFO CARD
|
||||||
_infoCard("Meta Info", [
|
_infoCard("Meta Info", [
|
||||||
_iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"),
|
_iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"),
|
||||||
_iconInfoRow(Icons.folder_shared, "Contact Buckets",
|
_iconInfoRow(Icons.folder_shared, "Contact Buckets", bucketNames.isNotEmpty ? bucketNames : "-"),
|
||||||
bucketNames.isNotEmpty ? bucketNames : "-"),
|
|
||||||
_iconInfoRow(Icons.work_outline, "Projects", projectNames),
|
_iconInfoRow(Icons.work_outline, "Projects", projectNames),
|
||||||
]),
|
]),
|
||||||
|
// DESCRIPTION CARD
|
||||||
_infoCard("Description", [
|
_infoCard("Description", [
|
||||||
MySpacing.height(6),
|
MySpacing.height(6),
|
||||||
Align(
|
Align(
|
||||||
@ -294,7 +268,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
textAlign: TextAlign.left,
|
textAlign: TextAlign.left,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
])
|
]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -309,25 +283,17 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result == true) {
|
if (result == true) {
|
||||||
await directoryController.fetchContacts();
|
await directoryController.fetchContacts();
|
||||||
final updated =
|
final updated =
|
||||||
directoryController.allContacts.firstWhereOrNull(
|
directoryController.allContacts.firstWhereOrNull((c) => c.id == contact.id);
|
||||||
(c) => c.id == contact.id,
|
|
||||||
);
|
|
||||||
if (updated != null) {
|
if (updated != null) {
|
||||||
setState(() {
|
setState(() => contact = updated);
|
||||||
contact = updated;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.edit, color: Colors.white),
|
icon: const Icon(Icons.edit, color: Colors.white),
|
||||||
label: const Text(
|
label: const Text("Edit Contact", style: TextStyle(color: Colors.white)),
|
||||||
"Edit Contact",
|
|
||||||
style: TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -337,24 +303,17 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
Widget _buildCommentsTab(BuildContext context) {
|
Widget _buildCommentsTab(BuildContext context) {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
final contactId = contact.id;
|
final contactId = contact.id;
|
||||||
|
|
||||||
if (!directoryController.contactCommentsMap.containsKey(contactId)) {
|
if (!directoryController.contactCommentsMap.containsKey(contactId)) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
final comments = directoryController.getCommentsForContact(contactId).reversed.toList();
|
||||||
final comments = directoryController
|
|
||||||
.getCommentsForContact(contactId)
|
|
||||||
.reversed
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final editingId = directoryController.editingCommentId.value;
|
final editingId = directoryController.editingCommentId.value;
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
comments.isEmpty
|
comments.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
child:
|
child: MyText.bodyLarge("No comments yet.", color: Colors.grey),
|
||||||
MyText.bodyLarge("No comments yet.", color: Colors.grey),
|
|
||||||
)
|
)
|
||||||
: Padding(
|
: Padding(
|
||||||
padding: MySpacing.xy(12, 12),
|
padding: MySpacing.xy(12, 12),
|
||||||
@ -362,137 +321,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
padding: const EdgeInsets.only(bottom: 100),
|
padding: const EdgeInsets.only(bottom: 100),
|
||||||
itemCount: comments.length,
|
itemCount: comments.length,
|
||||||
separatorBuilder: (_, __) => MySpacing.height(14),
|
separatorBuilder: (_, __) => MySpacing.height(14),
|
||||||
itemBuilder: (_, index) {
|
itemBuilder: (_, index) => _buildCommentItem(comments[index], editingId, contact.id),
|
||||||
final comment = comments[index];
|
|
||||||
final isEditing = editingId == comment.id;
|
|
||||||
|
|
||||||
final initials = comment.createdBy.firstName.isNotEmpty
|
|
||||||
? comment.createdBy.firstName[0].toUpperCase()
|
|
||||||
: "?";
|
|
||||||
|
|
||||||
final decodedDelta = HtmlToDelta().convert(comment.note);
|
|
||||||
|
|
||||||
final quillController = isEditing
|
|
||||||
? quill.QuillController(
|
|
||||||
document: quill.Document.fromDelta(decodedDelta),
|
|
||||||
selection: TextSelection.collapsed(
|
|
||||||
offset: decodedDelta.length),
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
padding: MySpacing.xy(8, 7),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isEditing ? Colors.indigo[50] : Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(
|
|
||||||
color: isEditing
|
|
||||||
? Colors.indigo
|
|
||||||
: Colors.grey.shade300,
|
|
||||||
width: 1.2,
|
|
||||||
),
|
|
||||||
boxShadow: const [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black12,
|
|
||||||
blurRadius: 4,
|
|
||||||
offset: Offset(0, 2),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Header Row
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Avatar(
|
|
||||||
firstName: initials,
|
|
||||||
lastName: '',
|
|
||||||
size: 36),
|
|
||||||
MySpacing.width(12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment:
|
|
||||||
CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MyText.bodyMedium(
|
|
||||||
"By: ${comment.createdBy.firstName}",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: Colors.indigo[800],
|
|
||||||
),
|
|
||||||
MySpacing.height(4),
|
|
||||||
MyText.bodySmall(
|
|
||||||
DateTimeUtils.convertUtcToLocal(
|
|
||||||
comment.createdAt.toString(),
|
|
||||||
format: 'dd MMM yyyy, hh:mm a',
|
|
||||||
),
|
|
||||||
color: Colors.grey[600],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
isEditing ? Icons.close : Icons.edit,
|
|
||||||
size: 20,
|
|
||||||
color: Colors.indigo,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
directoryController.editingCommentId.value =
|
|
||||||
isEditing ? null : comment.id;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// Comment Content
|
|
||||||
if (isEditing && quillController != null)
|
|
||||||
CommentEditorCard(
|
|
||||||
controller: quillController,
|
|
||||||
onCancel: () {
|
|
||||||
directoryController.editingCommentId.value =
|
|
||||||
null;
|
|
||||||
},
|
|
||||||
onSave: (controller) async {
|
|
||||||
final delta = controller.document.toDelta();
|
|
||||||
final htmlOutput = _convertDeltaToHtml(delta);
|
|
||||||
final updated =
|
|
||||||
comment.copyWith(note: htmlOutput);
|
|
||||||
|
|
||||||
await directoryController
|
|
||||||
.updateComment(updated);
|
|
||||||
|
|
||||||
// ✅ Re-fetch comments to get updated list
|
|
||||||
await directoryController
|
|
||||||
.fetchCommentsForContact(contactId);
|
|
||||||
|
|
||||||
// ✅ Exit editing mode
|
|
||||||
directoryController.editingCommentId.value =
|
|
||||||
null;
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else
|
|
||||||
html.Html(
|
|
||||||
data: comment.note,
|
|
||||||
style: {
|
|
||||||
"body": html.Style(
|
|
||||||
margin: html.Margins.zero,
|
|
||||||
padding: html.HtmlPaddings.zero,
|
|
||||||
fontSize: html.FontSize.medium,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (editingId == null)
|
||||||
// Floating Action Button
|
|
||||||
if (directoryController.editingCommentId.value == null)
|
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 20,
|
bottom: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
@ -503,17 +335,12 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
AddCommentBottomSheet(contactId: contactId),
|
AddCommentBottomSheet(contactId: contactId),
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result == true) {
|
if (result == true) {
|
||||||
await directoryController
|
await directoryController.fetchCommentsForContact(contactId);
|
||||||
.fetchCommentsForContact(contactId);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.add_comment, color: Colors.white),
|
icon: const Icon(Icons.add_comment, color: Colors.white),
|
||||||
label: const Text(
|
label: const Text("Add Comment", style: TextStyle(color: Colors.white)),
|
||||||
"Add Comment",
|
|
||||||
style: TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -521,25 +348,127 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _iconInfoRow(IconData icon, String label, String value,
|
Widget _buildCommentItem(comment, editingId, contactId) {
|
||||||
{VoidCallback? onTap, VoidCallback? onLongPress}) {
|
final isEditing = editingId == comment.id;
|
||||||
|
final initials = comment.createdBy.firstName.isNotEmpty
|
||||||
|
? comment.createdBy.firstName[0].toUpperCase()
|
||||||
|
: "?";
|
||||||
|
final decodedDelta = HtmlToDelta().convert(comment.note);
|
||||||
|
final quillController = isEditing
|
||||||
|
? quill.QuillController(
|
||||||
|
document: quill.Document.fromDelta(decodedDelta),
|
||||||
|
selection: TextSelection.collapsed(offset: decodedDelta.length),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
padding: MySpacing.xy(8, 7),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isEditing ? Colors.indigo[50] : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: isEditing ? Colors.indigo : Colors.grey.shade300,
|
||||||
|
width: 1.2,
|
||||||
|
),
|
||||||
|
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2))],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header Row
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Avatar(firstName: initials, lastName: '', size: 36),
|
||||||
|
MySpacing.width(12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodyMedium("By: ${comment.createdBy.firstName}",
|
||||||
|
fontWeight: 600, color: Colors.indigo[800]),
|
||||||
|
MySpacing.height(4),
|
||||||
|
MyText.bodySmall(
|
||||||
|
DateTimeUtils.convertUtcToLocal(
|
||||||
|
comment.createdAt.toString(),
|
||||||
|
format: 'dd MMM yyyy, hh:mm a',
|
||||||
|
),
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
isEditing ? Icons.close : Icons.edit,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.indigo,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
directoryController.editingCommentId.value = isEditing ? null : comment.id;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Comment Content
|
||||||
|
if (isEditing && quillController != null)
|
||||||
|
CommentEditorCard(
|
||||||
|
controller: quillController,
|
||||||
|
onCancel: () => directoryController.editingCommentId.value = null,
|
||||||
|
onSave: (ctrl) async {
|
||||||
|
final delta = ctrl.document.toDelta();
|
||||||
|
final htmlOutput = _convertDeltaToHtml(delta);
|
||||||
|
final updated = comment.copyWith(note: htmlOutput);
|
||||||
|
await directoryController.updateComment(updated);
|
||||||
|
await directoryController.fetchCommentsForContact(contactId);
|
||||||
|
directoryController.editingCommentId.value = null;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else
|
||||||
|
html.Html(
|
||||||
|
data: comment.note,
|
||||||
|
style: {
|
||||||
|
"body": html.Style(
|
||||||
|
margin: html.Margins.zero,
|
||||||
|
padding: html.HtmlPaddings.zero,
|
||||||
|
fontSize: html.FontSize.medium,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _iconInfoRow(
|
||||||
|
IconData? icon,
|
||||||
|
String label,
|
||||||
|
String value, {
|
||||||
|
VoidCallback? onTap,
|
||||||
|
VoidCallback? onLongPress,
|
||||||
|
}) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: MySpacing.y(8),
|
padding: MySpacing.y(2),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
onLongPress: onLongPress,
|
onLongPress: onLongPress,
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 22, color: Colors.indigo),
|
if (icon != null) ...[
|
||||||
MySpacing.width(12),
|
Icon(icon, size: 22, color: Colors.indigo),
|
||||||
|
MySpacing.width(12),
|
||||||
|
] else
|
||||||
|
const SizedBox(width: 34),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
MyText.bodySmall(label,
|
if (label.isNotEmpty)
|
||||||
fontWeight: 600, color: Colors.black87),
|
MyText.bodySmall(label, fontWeight: 600, color: Colors.black87),
|
||||||
MySpacing.height(2),
|
if (label.isNotEmpty) MySpacing.height(2),
|
||||||
MyText.bodyMedium(value, color: Colors.grey[800]),
|
MyText.bodyMedium(value, color: Colors.grey[800]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -560,8 +489,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
MyText.titleSmall(title,
|
MyText.titleSmall(title, fontWeight: 700, color: Colors.indigo[700]),
|
||||||
fontWeight: 700, color: Colors.indigo[700]),
|
|
||||||
MySpacing.height(8),
|
MySpacing.height(8),
|
||||||
...children,
|
...children,
|
||||||
],
|
],
|
||||||
@ -570,3 +498,26 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper widget for Project label in AppBar
|
||||||
|
class ProjectLabel extends StatelessWidget {
|
||||||
|
final String? projectName;
|
||||||
|
const ProjectLabel(this.projectName, {super.key});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.work_outline, size: 14, color: Colors.grey),
|
||||||
|
MySpacing.width(4),
|
||||||
|
Expanded(
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
projectName ?? 'Select Project',
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -24,8 +24,7 @@ class DirectoryView extends StatefulWidget {
|
|||||||
class _DirectoryViewState extends State<DirectoryView> {
|
class _DirectoryViewState extends State<DirectoryView> {
|
||||||
final DirectoryController controller = Get.find();
|
final DirectoryController controller = Get.find();
|
||||||
final TextEditingController searchController = TextEditingController();
|
final TextEditingController searchController = TextEditingController();
|
||||||
final PermissionController permissionController =
|
final PermissionController permissionController = Get.put(PermissionController());
|
||||||
Get.put(PermissionController());
|
|
||||||
|
|
||||||
Future<void> _refreshDirectory() async {
|
Future<void> _refreshDirectory() async {
|
||||||
try {
|
try {
|
||||||
@ -213,7 +212,7 @@ class _DirectoryViewState extends State<DirectoryView> {
|
|||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(Icons.filter_alt_outlined,
|
icon: Icon(Icons.tune,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: isFilterActive
|
color: isFilterActive
|
||||||
? Colors.indigo
|
? Colors.indigo
|
||||||
@ -267,7 +266,7 @@ class _DirectoryViewState extends State<DirectoryView> {
|
|||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
List<PopupMenuEntry<int>> menuItems = [];
|
List<PopupMenuEntry<int>> menuItems = [];
|
||||||
|
|
||||||
// Section: Actions (Always visible now)
|
// Section: Actions
|
||||||
menuItems.add(
|
menuItems.add(
|
||||||
const PopupMenuItem<int>(
|
const PopupMenuItem<int>(
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@ -282,6 +281,37 @@ class _DirectoryViewState extends State<DirectoryView> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Create Bucket option
|
||||||
|
menuItems.add(
|
||||||
|
PopupMenuItem<int>(
|
||||||
|
value: 2,
|
||||||
|
child: Row(
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.add_box_outlined,
|
||||||
|
size: 20, color: Colors.black87),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Expanded(child: Text("Create Bucket")),
|
||||||
|
Icon(Icons.chevron_right,
|
||||||
|
size: 20, color: Colors.red),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Future.delayed(Duration.zero, () async {
|
||||||
|
final created = await showModalBottomSheet<bool>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => const CreateBucketBottomSheet(),
|
||||||
|
);
|
||||||
|
if (created == true) {
|
||||||
|
await controller.fetchBuckets();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Manage Buckets option
|
||||||
menuItems.add(
|
menuItems.add(
|
||||||
PopupMenuItem<int>(
|
PopupMenuItem<int>(
|
||||||
value: 1,
|
value: 1,
|
||||||
@ -318,6 +348,7 @@ class _DirectoryViewState extends State<DirectoryView> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Show Inactive switch
|
||||||
menuItems.add(
|
menuItems.add(
|
||||||
PopupMenuItem<int>(
|
PopupMenuItem<int>(
|
||||||
value: 0,
|
value: 0,
|
||||||
@ -409,62 +440,69 @@ class _DirectoryViewState extends State<DirectoryView> {
|
|||||||
color: Colors.grey[700],
|
color: Colors.grey[700],
|
||||||
overflow: TextOverflow.ellipsis),
|
overflow: TextOverflow.ellipsis),
|
||||||
MySpacing.height(8),
|
MySpacing.height(8),
|
||||||
...contact.contactEmails.map((e) =>
|
|
||||||
GestureDetector(
|
// Show only the first email (if present)
|
||||||
onTap: () => LauncherUtils.launchEmail(
|
if (contact.contactEmails.isNotEmpty)
|
||||||
e.emailAddress),
|
GestureDetector(
|
||||||
onLongPress: () =>
|
onTap: () => LauncherUtils.launchEmail(
|
||||||
LauncherUtils.copyToClipboard(
|
contact.contactEmails.first.emailAddress),
|
||||||
e.emailAddress,
|
onLongPress: () =>
|
||||||
typeLabel: 'Email'),
|
LauncherUtils.copyToClipboard(
|
||||||
child: Padding(
|
contact.contactEmails.first.emailAddress,
|
||||||
padding:
|
typeLabel: 'Email',
|
||||||
const EdgeInsets.only(bottom: 4),
|
),
|
||||||
child: Row(
|
child: Padding(
|
||||||
children: [
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
const Icon(Icons.email_outlined,
|
|
||||||
size: 16, color: Colors.indigo),
|
|
||||||
MySpacing.width(4),
|
|
||||||
ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(
|
|
||||||
maxWidth: 180),
|
|
||||||
child: MyText.labelSmall(
|
|
||||||
e.emailAddress,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
color: Colors.indigo,
|
|
||||||
decoration:
|
|
||||||
TextDecoration.underline,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
...contact.contactPhones.map((p) => Padding(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
bottom: 8, top: 4),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
const Icon(Icons.email_outlined,
|
||||||
onTap: () =>
|
size: 16, color: Colors.indigo),
|
||||||
LauncherUtils.launchPhone(
|
MySpacing.width(4),
|
||||||
p.phoneNumber),
|
Expanded(
|
||||||
|
child: MyText.labelSmall(
|
||||||
|
contact.contactEmails.first.emailAddress,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
color: Colors.indigo,
|
||||||
|
decoration:
|
||||||
|
TextDecoration.underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Show only the first phone (if present)
|
||||||
|
if (contact.contactPhones.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
bottom: 8, top: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => LauncherUtils
|
||||||
|
.launchPhone(contact
|
||||||
|
.contactPhones
|
||||||
|
.first
|
||||||
|
.phoneNumber),
|
||||||
onLongPress: () =>
|
onLongPress: () =>
|
||||||
LauncherUtils.copyToClipboard(
|
LauncherUtils.copyToClipboard(
|
||||||
p.phoneNumber,
|
contact.contactPhones.first
|
||||||
typeLabel: 'Phone'),
|
.phoneNumber,
|
||||||
|
typeLabel: 'Phone',
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.phone_outlined,
|
const Icon(
|
||||||
|
Icons.phone_outlined,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: Colors.indigo),
|
color: Colors.indigo),
|
||||||
MySpacing.width(4),
|
MySpacing.width(4),
|
||||||
ConstrainedBox(
|
Expanded(
|
||||||
constraints:
|
|
||||||
const BoxConstraints(
|
|
||||||
maxWidth: 140),
|
|
||||||
child: MyText.labelSmall(
|
child: MyText.labelSmall(
|
||||||
p.phoneNumber,
|
contact.contactPhones.first
|
||||||
|
.phoneNumber,
|
||||||
overflow:
|
overflow:
|
||||||
TextOverflow.ellipsis,
|
TextOverflow.ellipsis,
|
||||||
color: Colors.indigo,
|
color: Colors.indigo,
|
||||||
@ -475,19 +513,22 @@ class _DirectoryViewState extends State<DirectoryView> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
MySpacing.width(8),
|
),
|
||||||
GestureDetector(
|
MySpacing.width(8),
|
||||||
onTap: () =>
|
GestureDetector(
|
||||||
LauncherUtils.launchWhatsApp(
|
onTap: () =>
|
||||||
p.phoneNumber),
|
LauncherUtils.launchWhatsApp(
|
||||||
child: const FaIcon(
|
contact.contactPhones.first
|
||||||
FontAwesomeIcons.whatsapp,
|
.phoneNumber),
|
||||||
color: Colors.green,
|
child: const FaIcon(
|
||||||
size: 16),
|
FontAwesomeIcons.whatsapp,
|
||||||
|
color: Colors.green,
|
||||||
|
size: 16,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
if (tags.isNotEmpty) ...[
|
if (tags.isNotEmpty) ...[
|
||||||
MySpacing.height(2),
|
MySpacing.height(2),
|
||||||
MyText.labelSmall(tags.join(', '),
|
MyText.labelSmall(tags.join(', '),
|
||||||
|
|||||||
192
lib/view/employees/assign_employee_bottom_sheet.dart
Normal file
192
lib/view/employees/assign_employee_bottom_sheet.dart
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/controller/employee/assign_projects_controller.dart';
|
||||||
|
import 'package:marco/model/global_project_model.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
|
||||||
|
class AssignProjectBottomSheet extends StatefulWidget {
|
||||||
|
final String employeeId;
|
||||||
|
final String jobRoleId;
|
||||||
|
|
||||||
|
const AssignProjectBottomSheet({
|
||||||
|
super.key,
|
||||||
|
required this.employeeId,
|
||||||
|
required this.jobRoleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AssignProjectBottomSheet> createState() =>
|
||||||
|
_AssignProjectBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssignProjectBottomSheetState extends State<AssignProjectBottomSheet> {
|
||||||
|
late final AssignProjectController assignController;
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
assignController = Get.put(
|
||||||
|
AssignProjectController(
|
||||||
|
employeeId: widget.employeeId,
|
||||||
|
jobRoleId: widget.jobRoleId,
|
||||||
|
),
|
||||||
|
tag: '${widget.employeeId}_${widget.jobRoleId}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GetBuilder<AssignProjectController>(
|
||||||
|
tag: '${widget.employeeId}_${widget.jobRoleId}',
|
||||||
|
builder: (_) {
|
||||||
|
return BaseBottomSheet(
|
||||||
|
title: "Assign to Project",
|
||||||
|
onCancel: () => Navigator.pop(context),
|
||||||
|
onSubmit: _handleAssign,
|
||||||
|
submitText: "Assign",
|
||||||
|
child: Obx(() {
|
||||||
|
if (assignController.isLoading.value) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final projects = assignController.allProjects;
|
||||||
|
if (projects.isEmpty) {
|
||||||
|
return const Center(child: Text('No projects available.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodySmall(
|
||||||
|
'Select the projects to assign this employee.',
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
|
||||||
|
// Select All
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Projects (${projects.length})',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
assignController.toggleSelectAll();
|
||||||
|
},
|
||||||
|
child: Obx(() {
|
||||||
|
return Text(
|
||||||
|
assignController.areAllSelected()
|
||||||
|
? 'Deselect All'
|
||||||
|
: 'Select All',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.blueAccent,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// List of Projects
|
||||||
|
SizedBox(
|
||||||
|
height: 300,
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
itemCount: projects.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final GlobalProjectModel project = projects[index];
|
||||||
|
return Obx(() {
|
||||||
|
final bool isSelected =
|
||||||
|
assignController.isProjectSelected(
|
||||||
|
project.id.toString(),
|
||||||
|
);
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
checkboxTheme: CheckboxThemeData(
|
||||||
|
fillColor: WidgetStateProperty.resolveWith<Color>(
|
||||||
|
(states) => states.contains(WidgetState.selected)
|
||||||
|
? Colors.blueAccent
|
||||||
|
: Colors.white,
|
||||||
|
),
|
||||||
|
side: const BorderSide(
|
||||||
|
color: Colors.black,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
checkColor:
|
||||||
|
WidgetStateProperty.all(Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: CheckboxListTile(
|
||||||
|
dense: true,
|
||||||
|
value: isSelected,
|
||||||
|
title: Text(
|
||||||
|
project.name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (checked) {
|
||||||
|
assignController.toggleProjectSelection(
|
||||||
|
project.id.toString(),
|
||||||
|
checked ?? false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
activeColor: Colors.blueAccent,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
visualDensity: const VisualDensity(
|
||||||
|
horizontal: -4,
|
||||||
|
vertical: -4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleAssign() async {
|
||||||
|
if (assignController.selectedProjects.isEmpty) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Please select at least one project.",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await assignController.assignProjectsToEmployee();
|
||||||
|
if (success) {
|
||||||
|
Get.back();
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Success",
|
||||||
|
message: "Employee assigned to selected projects.",
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to assign employee.",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
321
lib/view/employees/employee_detail_screen.dart
Normal file
321
lib/view/employees/employee_detail_screen.dart
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:marco/controller/dashboard/employees_screen_controller.dart';
|
||||||
|
import 'package:marco/controller/project_controller.dart';
|
||||||
|
import 'package:marco/helpers/widgets/avatar.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/view/employees/assign_employee_bottom_sheet.dart';
|
||||||
|
import 'package:marco/helpers/utils/launcher_utils.dart';
|
||||||
|
|
||||||
|
class EmployeeDetailPage extends StatefulWidget {
|
||||||
|
final String employeeId;
|
||||||
|
|
||||||
|
const EmployeeDetailPage({super.key, required this.employeeId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EmployeeDetailPage> createState() => _EmployeeDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
|
||||||
|
final EmployeesScreenController controller =
|
||||||
|
Get.put(EmployeesScreenController());
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
controller.fetchEmployeeDetails(widget.employeeId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
controller.selectedEmployeeDetails.value = null;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getDisplayValue(dynamic value) {
|
||||||
|
if (value == null || value.toString().trim().isEmpty || value == 'null') {
|
||||||
|
return 'NA';
|
||||||
|
}
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime? date) {
|
||||||
|
if (date == null || date == DateTime(1)) return 'NA';
|
||||||
|
try {
|
||||||
|
return DateFormat('d/M/yyyy').format(date);
|
||||||
|
} catch (_) {
|
||||||
|
return 'NA';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row builder with email/phone tap & copy support
|
||||||
|
Widget _buildLabelValueRow(String label, String value,
|
||||||
|
{bool isMultiLine = false}) {
|
||||||
|
final lowerLabel = label.toLowerCase();
|
||||||
|
final isEmail = lowerLabel == 'email';
|
||||||
|
final isPhone = lowerLabel == 'phone number' ||
|
||||||
|
lowerLabel == 'emergency phone number';
|
||||||
|
|
||||||
|
void handleTap() {
|
||||||
|
if (value == 'NA') return;
|
||||||
|
if (isEmail) {
|
||||||
|
LauncherUtils.launchEmail(value);
|
||||||
|
} else if (isPhone) {
|
||||||
|
LauncherUtils.launchPhone(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleLongPress() {
|
||||||
|
if (value == 'NA') return;
|
||||||
|
LauncherUtils.copyToClipboard(value, typeLabel: label);
|
||||||
|
}
|
||||||
|
|
||||||
|
final valueWidget = GestureDetector(
|
||||||
|
onTap: (isEmail || isPhone) ? handleTap : null,
|
||||||
|
onLongPress: (isEmail || isPhone) ? handleLongPress : null,
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
color: (isEmail || isPhone) ? Colors.indigo : Colors.black54,
|
||||||
|
fontSize: 14,
|
||||||
|
decoration:
|
||||||
|
(isEmail || isPhone) ? TextDecoration.underline : TextDecoration.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (isMultiLine) ...[
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black87,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(4),
|
||||||
|
valueWidget,
|
||||||
|
] else
|
||||||
|
GestureDetector(
|
||||||
|
onTap: (isEmail || isPhone) ? handleTap : null,
|
||||||
|
onLongPress: (isEmail || isPhone) ? handleLongPress : null,
|
||||||
|
child: RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
text: "$label: ",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black87,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
color:
|
||||||
|
(isEmail || isPhone) ? Colors.indigo : Colors.black54,
|
||||||
|
decoration: (isEmail || isPhone)
|
||||||
|
? TextDecoration.underline
|
||||||
|
: TextDecoration.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(10),
|
||||||
|
Divider(color: Colors.grey[300], height: 1),
|
||||||
|
MySpacing.height(10),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Info card
|
||||||
|
Widget _buildInfoCard(employee) {
|
||||||
|
return Card(
|
||||||
|
elevation: 3,
|
||||||
|
shadowColor: Colors.black12,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 16, 12, 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MySpacing.height(12),
|
||||||
|
_buildLabelValueRow('Email', _getDisplayValue(employee.email)),
|
||||||
|
_buildLabelValueRow(
|
||||||
|
'Phone Number', _getDisplayValue(employee.phoneNumber)),
|
||||||
|
_buildLabelValueRow('Emergency Contact Person',
|
||||||
|
_getDisplayValue(employee.emergencyContactPerson)),
|
||||||
|
_buildLabelValueRow('Emergency Phone Number',
|
||||||
|
_getDisplayValue(employee.emergencyPhoneNumber)),
|
||||||
|
_buildLabelValueRow('Gender', _getDisplayValue(employee.gender)),
|
||||||
|
_buildLabelValueRow('Birth Date', _formatDate(employee.birthDate)),
|
||||||
|
_buildLabelValueRow(
|
||||||
|
'Joining Date', _formatDate(employee.joiningDate)),
|
||||||
|
_buildLabelValueRow(
|
||||||
|
'Current Address',
|
||||||
|
_getDisplayValue(employee.currentAddress),
|
||||||
|
isMultiLine: true,
|
||||||
|
),
|
||||||
|
_buildLabelValueRow(
|
||||||
|
'Permanent Address',
|
||||||
|
_getDisplayValue(employee.permanentAddress),
|
||||||
|
isMultiLine: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF1F1F1),
|
||||||
|
appBar: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(72),
|
||||||
|
child: AppBar(
|
||||||
|
backgroundColor: const Color(0xFFF5F5F5),
|
||||||
|
elevation: 0.5,
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
titleSpacing: 0,
|
||||||
|
title: Padding(
|
||||||
|
padding: MySpacing.xy(16, 0),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new,
|
||||||
|
color: Colors.black, size: 20),
|
||||||
|
onPressed: () => Get.offNamed('/dashboard/employees'),
|
||||||
|
),
|
||||||
|
MySpacing.width(8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
MyText.titleLarge(
|
||||||
|
'Employee Details',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
MySpacing.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),
|
||||||
|
MySpacing.width(4),
|
||||||
|
Expanded(
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
projectName,
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Obx(() {
|
||||||
|
if (controller.isLoadingEmployeeDetails.value) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final employee = controller.selectedEmployeeDetails.value;
|
||||||
|
if (employee == null) {
|
||||||
|
return const Center(child: Text('No employee details found.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 20, 12, 80),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Avatar(
|
||||||
|
firstName: employee.firstName,
|
||||||
|
lastName: employee.lastName,
|
||||||
|
size: 45,
|
||||||
|
),
|
||||||
|
MySpacing.width(16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.titleMedium(
|
||||||
|
'${employee.firstName} ${employee.lastName}',
|
||||||
|
fontWeight: 700,
|
||||||
|
),
|
||||||
|
MySpacing.height(6),
|
||||||
|
MyText.bodySmall(
|
||||||
|
_getDisplayValue(employee.jobRole),
|
||||||
|
fontWeight: 500,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(14),
|
||||||
|
_buildInfoCard(employee),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
floatingActionButton: Obx(() {
|
||||||
|
if (controller.isLoadingEmployeeDetails.value ||
|
||||||
|
controller.selectedEmployeeDetails.value == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final employee = controller.selectedEmployeeDetails.value!;
|
||||||
|
return FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => AssignProjectBottomSheet(
|
||||||
|
employeeId: widget.employeeId,
|
||||||
|
jobRoleId: employee.jobRoleId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
icon: const Icon(Icons.assignment),
|
||||||
|
label: const Text(
|
||||||
|
'Assign to Project',
|
||||||
|
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,19 +2,17 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:marco/helpers/theme/app_theme.dart';
|
import 'package:marco/helpers/theme/app_theme.dart';
|
||||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||||
import 'package:marco/helpers/utils/my_shadow.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_breadcrumb.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_breadcrumb_item.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_card.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
import 'package:marco/controller/permission_controller.dart';
|
|
||||||
import 'package:marco/model/employees/add_employee_bottom_sheet.dart';
|
import 'package:marco/model/employees/add_employee_bottom_sheet.dart';
|
||||||
import 'package:marco/controller/dashboard/employees_screen_controller.dart';
|
import 'package:marco/controller/dashboard/employees_screen_controller.dart';
|
||||||
import 'package:marco/helpers/widgets/avatar.dart';
|
import 'package:marco/helpers/widgets/avatar.dart';
|
||||||
import 'package:marco/model/employees/employee_detail_bottom_sheet.dart';
|
|
||||||
import 'package:marco/controller/project_controller.dart';
|
import 'package:marco/controller/project_controller.dart';
|
||||||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||||
|
import 'package:marco/view/employees/employee_detail_screen.dart';
|
||||||
|
import 'package:marco/model/employee_model.dart';
|
||||||
|
import 'package:marco/helpers/utils/launcher_utils.dart';
|
||||||
|
import 'package:marco/view/employees/assign_employee_bottom_sheet.dart';
|
||||||
|
|
||||||
class EmployeesScreen extends StatefulWidget {
|
class EmployeesScreen extends StatefulWidget {
|
||||||
const EmployeesScreen({super.key});
|
const EmployeesScreen({super.key});
|
||||||
@ -24,270 +22,133 @@ class EmployeesScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||||
final EmployeesScreenController employeeScreenController =
|
final EmployeesScreenController _employeeController = Get.put(EmployeesScreenController());
|
||||||
Get.put(EmployeesScreenController());
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final PermissionController permissionController =
|
final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs;
|
||||||
Get.put(PermissionController());
|
|
||||||
Future<void> _refreshEmployees() async {
|
|
||||||
try {
|
|
||||||
final selectedProjectId =
|
|
||||||
Get.find<ProjectController>().selectedProject?.id;
|
|
||||||
final isAllSelected =
|
|
||||||
employeeScreenController.isAllEmployeeSelected.value;
|
|
||||||
|
|
||||||
if (isAllSelected) {
|
|
||||||
employeeScreenController.selectedProjectId = null;
|
|
||||||
await employeeScreenController.fetchAllEmployees();
|
|
||||||
} else if (selectedProjectId != null) {
|
|
||||||
employeeScreenController.selectedProjectId = selectedProjectId;
|
|
||||||
await employeeScreenController
|
|
||||||
.fetchEmployeesByProject(selectedProjectId);
|
|
||||||
} else {
|
|
||||||
// ❗ Clear employees if neither selected
|
|
||||||
employeeScreenController.clearEmployees();
|
|
||||||
}
|
|
||||||
|
|
||||||
employeeScreenController.update(['employee_screen_controller']);
|
|
||||||
} catch (e, stackTrace) {
|
|
||||||
debugPrint('Error refreshing employee data: ${e.toString()}');
|
|
||||||
debugPrintStack(stackTrace: stackTrace);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final selectedProjectId = Get.find<ProjectController>().selectedProject?.id;
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_initEmployees();
|
||||||
|
_searchController.addListener(() => _filterEmployees(_searchController.text));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedProjectId != null) {
|
Future<void> _initEmployees() async {
|
||||||
employeeScreenController.selectedProjectId = selectedProjectId;
|
final projectId = Get.find<ProjectController>().selectedProject?.id;
|
||||||
employeeScreenController.fetchEmployeesByProject(selectedProjectId);
|
|
||||||
} else if (employeeScreenController.isAllEmployeeSelected.value) {
|
if (_employeeController.isAllEmployeeSelected.value) {
|
||||||
employeeScreenController.selectedProjectId = null;
|
_employeeController.selectedProjectId = null;
|
||||||
employeeScreenController.fetchAllEmployees();
|
await _employeeController.fetchAllEmployees();
|
||||||
|
} else if (projectId != null) {
|
||||||
|
_employeeController.selectedProjectId = projectId;
|
||||||
|
await _employeeController.fetchEmployeesByProject(projectId);
|
||||||
} else {
|
} else {
|
||||||
employeeScreenController.clearEmployees();
|
_employeeController.clearEmployees();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_filterEmployees(_searchController.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshEmployees() async {
|
||||||
|
try {
|
||||||
|
final projectId = Get.find<ProjectController>().selectedProject?.id;
|
||||||
|
final allSelected = _employeeController.isAllEmployeeSelected.value;
|
||||||
|
|
||||||
|
_employeeController.selectedProjectId = allSelected ? null : projectId;
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
await _employeeController.fetchAllEmployees();
|
||||||
|
} else if (projectId != null) {
|
||||||
|
await _employeeController.fetchEmployeesByProject(projectId);
|
||||||
|
} else {
|
||||||
|
_employeeController.clearEmployees();
|
||||||
|
}
|
||||||
|
|
||||||
|
_filterEmployees(_searchController.text);
|
||||||
|
_employeeController.update(['employee_screen_controller']);
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
debugPrint('Error refreshing employee data: $e');
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _filterEmployees(String query) {
|
||||||
|
final employees = _employeeController.employees;
|
||||||
|
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_filteredEmployees.assignAll(employees);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final q = query.toLowerCase();
|
||||||
|
_filteredEmployees.assignAll(
|
||||||
|
employees.where((e) =>
|
||||||
|
e.name.toLowerCase().contains(q) ||
|
||||||
|
e.email.toLowerCase().contains(q) ||
|
||||||
|
e.phoneNumber.toLowerCase().contains(q) ||
|
||||||
|
e.jobRole.toLowerCase().contains(q),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onAddNewEmployee() async {
|
||||||
|
final result = await showModalBottomSheet<Map<String, dynamic>>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => AddEmployeeBottomSheet(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == null || result['success'] != true) return;
|
||||||
|
|
||||||
|
final employeeData = result['data'];
|
||||||
|
final employeeId = employeeData['id'] as String;
|
||||||
|
final jobRoleId = employeeData['jobRoleId'] as String?;
|
||||||
|
|
||||||
|
await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => AssignProjectBottomSheet(
|
||||||
|
employeeId: employeeId,
|
||||||
|
jobRoleId: jobRoleId ?? '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _refreshEmployees();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF5F5F5),
|
backgroundColor: Colors.white,
|
||||||
appBar: PreferredSize(
|
appBar: _buildAppBar(),
|
||||||
preferredSize: const Size.fromHeight(72),
|
floatingActionButton: _buildFloatingActionButton(),
|
||||||
child: AppBar(
|
|
||||||
backgroundColor: const Color(0xFFF5F5F5),
|
|
||||||
elevation: 0.5,
|
|
||||||
automaticallyImplyLeading: false,
|
|
||||||
titleSpacing: 0,
|
|
||||||
title: Padding(
|
|
||||||
padding: MySpacing.xy(16, 0),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
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,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
MyText.titleLarge(
|
|
||||||
'Employees',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: Colors.black,
|
|
||||||
),
|
|
||||||
MySpacing.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),
|
|
||||||
MySpacing.width(4),
|
|
||||||
Expanded(
|
|
||||||
child: MyText.bodySmall(
|
|
||||||
projectName,
|
|
||||||
fontWeight: 600,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
color: Colors.grey[700],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
floatingActionButton: InkWell(
|
|
||||||
onTap: () async {
|
|
||||||
final result = await showModalBottomSheet<bool>(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
||||||
),
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (context) => AddEmployeeBottomSheet(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result == true) {
|
|
||||||
await _refreshEmployees();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(28),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.blueAccent,
|
|
||||||
borderRadius: BorderRadius.circular(28),
|
|
||||||
boxShadow: const [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black26,
|
|
||||||
blurRadius: 6,
|
|
||||||
offset: Offset(0, 3),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: const Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.add, color: Colors.white),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('Add New Employee', style: TextStyle(color: Colors.white)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: GetBuilder<EmployeesScreenController>(
|
child: GetBuilder<EmployeesScreenController>(
|
||||||
init: employeeScreenController,
|
init: _employeeController,
|
||||||
tag: 'employee_screen_controller',
|
tag: 'employee_screen_controller',
|
||||||
builder: (controller) {
|
builder: (_) {
|
||||||
|
_filterEmployees(_searchController.text);
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.only(bottom: 80),
|
padding: const EdgeInsets.only(bottom: 40),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
MySpacing.height(flexSpacing),
|
||||||
padding: MySpacing.x(flexSpacing),
|
_buildSearchAndActionRow(),
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
MyBreadcrumb(
|
|
||||||
children: [
|
|
||||||
MyBreadcrumbItem(name: 'Dashboard'),
|
|
||||||
MyBreadcrumbItem(name: 'Employees', active: true),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.height(flexSpacing),
|
MySpacing.height(flexSpacing),
|
||||||
Padding(
|
Padding(
|
||||||
padding: MySpacing.x(flexSpacing),
|
padding: MySpacing.x(flexSpacing),
|
||||||
child: Row(
|
child: _buildEmployeeList(),
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Obx(() {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Checkbox(
|
|
||||||
value: employeeScreenController
|
|
||||||
.isAllEmployeeSelected.value,
|
|
||||||
activeColor: Colors.blueAccent,
|
|
||||||
fillColor:
|
|
||||||
MaterialStateProperty.resolveWith<Color?>(
|
|
||||||
(states) {
|
|
||||||
if (states.contains(MaterialState.selected)) {
|
|
||||||
return Colors.blueAccent;
|
|
||||||
}
|
|
||||||
return Colors.transparent;
|
|
||||||
}),
|
|
||||||
checkColor: Colors.white,
|
|
||||||
side: BorderSide(
|
|
||||||
color: Colors.black,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
onChanged: (value) async {
|
|
||||||
employeeScreenController
|
|
||||||
.isAllEmployeeSelected.value = value!;
|
|
||||||
|
|
||||||
if (value) {
|
|
||||||
employeeScreenController.selectedProjectId =
|
|
||||||
null;
|
|
||||||
await employeeScreenController
|
|
||||||
.fetchAllEmployees();
|
|
||||||
} else {
|
|
||||||
final selectedProjectId =
|
|
||||||
Get.find<ProjectController>()
|
|
||||||
.selectedProject
|
|
||||||
?.id;
|
|
||||||
|
|
||||||
if (selectedProjectId != null) {
|
|
||||||
employeeScreenController
|
|
||||||
.selectedProjectId =
|
|
||||||
selectedProjectId;
|
|
||||||
await employeeScreenController
|
|
||||||
.fetchEmployeesByProject(
|
|
||||||
selectedProjectId);
|
|
||||||
} else {
|
|
||||||
// ✅ THIS is your critical path
|
|
||||||
employeeScreenController.clearEmployees();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
employeeScreenController
|
|
||||||
.update(['employee_screen_controller']);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
MyText.bodyMedium(
|
|
||||||
"All Employees",
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
MyText.bodyMedium("Refresh", fontWeight: 600),
|
|
||||||
Tooltip(
|
|
||||||
message: 'Refresh Data',
|
|
||||||
child: InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
onTap: _refreshEmployees,
|
|
||||||
child: MouseRegion(
|
|
||||||
cursor: SystemMouseCursors.click,
|
|
||||||
child: const Padding(
|
|
||||||
padding: EdgeInsets.all(8),
|
|
||||||
child: Icon(
|
|
||||||
Icons.refresh,
|
|
||||||
color: Colors.green,
|
|
||||||
size: 28,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: MySpacing.x(flexSpacing),
|
|
||||||
child: dailyProgressReportTab(),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -298,119 +159,290 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget dailyProgressReportTab() {
|
PreferredSizeWidget _buildAppBar() {
|
||||||
|
return PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(72),
|
||||||
|
child: 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('Employees', fontWeight: 700, color: Colors.black),
|
||||||
|
MySpacing.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),
|
||||||
|
MySpacing.width(4),
|
||||||
|
Expanded(
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
projectName,
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFloatingActionButton() {
|
||||||
|
return InkWell(
|
||||||
|
onTap: _onAddNewEmployee,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6, offset: Offset(0, 3))],
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.add, color: Colors.white),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Add New Employee', style: TextStyle(color: Colors.white)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSearchAndActionRow() {
|
||||||
|
return Padding(
|
||||||
|
padding: MySpacing.x(flexSpacing),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildSearchField()),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildRefreshButton(),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
_buildPopupMenu(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSearchField() {
|
||||||
|
return SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
style: const TextStyle(fontSize: 13, height: 1.2),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey),
|
||||||
|
prefixIconConstraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||||
|
hintText: 'Search contacts...',
|
||||||
|
hintStyle: const TextStyle(fontSize: 13, color: Colors.grey),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300, width: 1),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300, width: 1),
|
||||||
|
),
|
||||||
|
suffixIcon: _searchController.text.isNotEmpty
|
||||||
|
? GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
_searchController.clear();
|
||||||
|
_filterEmployees('');
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
child: const Icon(Icons.close, size: 18, color: Colors.grey),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRefreshButton() {
|
||||||
|
return Tooltip(
|
||||||
|
message: 'Refresh Data',
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
onTap: _refreshEmployees,
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(10),
|
||||||
|
child: Icon(Icons.refresh, color: Colors.green, size: 28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPopupMenu() {
|
||||||
|
return PopupMenuButton<String>(
|
||||||
|
icon: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.tune, color: Colors.black),
|
||||||
|
Obx(() => _employeeController.isAllEmployeeSelected.value
|
||||||
|
? Positioned(
|
||||||
|
right: -1,
|
||||||
|
top: -1,
|
||||||
|
child: Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onSelected: (value) async {
|
||||||
|
if (value == 'all_employees') {
|
||||||
|
_employeeController.isAllEmployeeSelected.toggle();
|
||||||
|
await _initEmployees();
|
||||||
|
_employeeController.update(['employee_screen_controller']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
PopupMenuItem<String>(
|
||||||
|
value: 'all_employees',
|
||||||
|
child: Obx(
|
||||||
|
() => Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: _employeeController.isAllEmployeeSelected.value,
|
||||||
|
onChanged: (_) => Navigator.pop(context, 'all_employees'),
|
||||||
|
checkColor: Colors.white,
|
||||||
|
activeColor: Colors.red,
|
||||||
|
side: const BorderSide(color: Colors.black, width: 1.5),
|
||||||
|
fillColor: MaterialStateProperty.resolveWith<Color>(
|
||||||
|
(states) => states.contains(MaterialState.selected) ? Colors.red : Colors.white),
|
||||||
|
),
|
||||||
|
const Text('All Employees'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmployeeList() {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
final isLoading = employeeScreenController.isLoading.value;
|
if (_employeeController.isLoading.value) {
|
||||||
final employees = employeeScreenController.employees;
|
return ListView.separated(
|
||||||
if (isLoading) {
|
shrinkWrap: true,
|
||||||
return SkeletonLoaders.employeeListSkeletonLoader();
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: 8,
|
||||||
|
separatorBuilder: (_, __) => MySpacing.height(12),
|
||||||
|
itemBuilder: (_, __) => SkeletonLoaders.employeeSkeletonCard(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final employees = _filteredEmployees;
|
||||||
|
|
||||||
if (employees.isEmpty) {
|
if (employees.isEmpty) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 50),
|
padding: const EdgeInsets.only(top: 60),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: MyText.bodySmall(
|
child: MyText.bodySmall("No Employees Found", fontWeight: 600, color: Colors.grey[700]),
|
||||||
"No Assigned Employees Found",
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.only(bottom: 80),
|
return ListView.separated(
|
||||||
child: Column(
|
shrinkWrap: true,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
children: employees.map((employee) {
|
padding: MySpacing.only(bottom: 80),
|
||||||
return InkWell(
|
itemCount: employees.length,
|
||||||
onTap: () {
|
separatorBuilder: (_, __) => MySpacing.height(12),
|
||||||
showModalBottomSheet(
|
itemBuilder: (_, index) {
|
||||||
context: context,
|
final e = employees[index];
|
||||||
isScrollControlled: true,
|
final names = e.name.trim().split(' ');
|
||||||
backgroundColor: Colors.transparent,
|
final firstName = names.first;
|
||||||
builder: (context) =>
|
final lastName = names.length > 1 ? names.last : '';
|
||||||
EmployeeDetailBottomSheet(employeeId: employee.id),
|
|
||||||
);
|
return InkWell(
|
||||||
},
|
onTap: () => Get.to(() => EmployeeDetailPage(employeeId: e.id)),
|
||||||
child: MyCard.bordered(
|
child: Row(
|
||||||
borderRadiusAll: 12,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
paddingAll: 10,
|
children: [
|
||||||
margin: MySpacing.bottom(12),
|
Avatar(firstName: firstName, lastName: lastName, size: 35),
|
||||||
shadow: MyShadow(elevation: 3),
|
MySpacing.width(12),
|
||||||
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
MyText.titleSmall(e.name, fontWeight: 600, overflow: TextOverflow.ellipsis),
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
if (e.jobRole.isNotEmpty)
|
||||||
children: [
|
MyText.bodySmall(e.jobRole, color: Colors.grey[700], overflow: TextOverflow.ellipsis),
|
||||||
Avatar(
|
MySpacing.height(8),
|
||||||
firstName: employee.firstName,
|
if (e.email.isNotEmpty && e.email != '-')
|
||||||
lastName: employee.lastName,
|
_buildLinkRow(icon: Icons.email_outlined, text: e.email, onTap: () => LauncherUtils.launchEmail(e.email), onLongPress: () => LauncherUtils.copyToClipboard(e.email, typeLabel: 'Email')),
|
||||||
size: 41,
|
if (e.email.isNotEmpty && e.email != '-') MySpacing.height(6),
|
||||||
),
|
if (e.phoneNumber.isNotEmpty)
|
||||||
const SizedBox(width: 16),
|
_buildLinkRow(icon: Icons.phone_outlined, text: e.phoneNumber, onTap: () => LauncherUtils.launchPhone(e.phoneNumber), onLongPress: () => LauncherUtils.copyToClipboard(e.phoneNumber, typeLabel: 'Phone')),
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Wrap(
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
spacing: 6,
|
|
||||||
children: [
|
|
||||||
MyText.titleMedium(
|
|
||||||
employee.name,
|
|
||||||
fontWeight: 600,
|
|
||||||
overflow: TextOverflow.visible,
|
|
||||||
maxLines: null,
|
|
||||||
),
|
|
||||||
MyText.titleSmall(
|
|
||||||
'(${employee.jobRole})',
|
|
||||||
fontWeight: 400,
|
|
||||||
overflow: TextOverflow.visible,
|
|
||||||
maxLines: null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
if (employee.email.isNotEmpty &&
|
|
||||||
employee.email != '-') ...[
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.email,
|
|
||||||
size: 16, color: Colors.red),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Flexible(
|
|
||||||
child: MyText.titleSmall(
|
|
||||||
employee.email,
|
|
||||||
fontWeight: 400,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.phone,
|
|
||||||
size: 16, color: Colors.blueAccent),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
MyText.titleSmall(
|
|
||||||
employee.phoneNumber,
|
|
||||||
fontWeight: 400,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
));
|
),
|
||||||
}).toList(),
|
const Icon(Icons.arrow_forward_ios, color: Colors.grey, size: 16),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildLinkRow({
|
||||||
|
required IconData icon,
|
||||||
|
required String text,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
required VoidCallback onLongPress,
|
||||||
|
}) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16, color: Colors.indigo),
|
||||||
|
MySpacing.width(4),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 180),
|
||||||
|
child: MyText.labelSmall(
|
||||||
|
text,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
color: Colors.indigo,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
668
lib/view/expense/expense_detail_screen.dart
Normal file
668
lib/view/expense/expense_detail_screen.dart
Normal file
@ -0,0 +1,668 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/controller/expense/expense_detail_controller.dart';
|
||||||
|
import 'package:marco/controller/project_controller.dart';
|
||||||
|
import 'package:marco/controller/permission_controller.dart';
|
||||||
|
import 'package:marco/helpers/utils/date_time_utils.dart';
|
||||||
|
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/model/expense/add_expense_bottom_sheet.dart';
|
||||||
|
import 'package:marco/model/expense/comment_bottom_sheet.dart';
|
||||||
|
import 'package:marco/model/expense/expense_detail_model.dart';
|
||||||
|
import 'package:marco/model/expense/reimbursement_bottom_sheet.dart';
|
||||||
|
import 'package:marco/controller/expense/add_expense_controller.dart';
|
||||||
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import 'package:marco/helpers/widgets/expense_detail_helpers.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||||
|
import 'package:marco/model/employee_info.dart';
|
||||||
|
|
||||||
|
class ExpenseDetailScreen extends StatefulWidget {
|
||||||
|
final String expenseId;
|
||||||
|
const ExpenseDetailScreen({super.key, required this.expenseId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ExpenseDetailScreen> createState() => _ExpenseDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
|
||||||
|
final controller = Get.put(ExpenseDetailController());
|
||||||
|
final projectController = Get.find<ProjectController>();
|
||||||
|
final permissionController = Get.find<PermissionController>();
|
||||||
|
|
||||||
|
EmployeeInfo? employeeInfo;
|
||||||
|
final RxBool canSubmit = false.obs;
|
||||||
|
bool _checkedPermission = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
controller.init(widget.expenseId);
|
||||||
|
_loadEmployeeInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadEmployeeInfo() async {
|
||||||
|
final info = await LocalStorage.getEmployeeInfo();
|
||||||
|
employeeInfo = info;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _checkPermissionToSubmit(ExpenseDetailModel expense) {
|
||||||
|
const allowedNextStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
|
||||||
|
|
||||||
|
final isCreatedByCurrentUser = employeeInfo?.id == expense.createdBy.id;
|
||||||
|
final nextStatusIds = expense.nextStatus.map((e) => e.id).toList();
|
||||||
|
final hasRequiredNextStatus = nextStatusIds.contains(allowedNextStatusId);
|
||||||
|
|
||||||
|
final result = isCreatedByCurrentUser && hasRequiredNextStatus;
|
||||||
|
|
||||||
|
logSafe(
|
||||||
|
'🐛 Checking submit permission:\n'
|
||||||
|
'🐛 - Logged-in employee ID: ${employeeInfo?.id}\n'
|
||||||
|
'🐛 - Expense created by ID: ${expense.createdBy.id}\n'
|
||||||
|
'🐛 - Next Status IDs: $nextStatusIds\n'
|
||||||
|
'🐛 - Has Required Next Status ID ($allowedNextStatusId): $hasRequiredNextStatus\n'
|
||||||
|
'🐛 - Final Permission Result: $result',
|
||||||
|
level: LogLevel.debug,
|
||||||
|
);
|
||||||
|
|
||||||
|
canSubmit.value = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF7F7F7),
|
||||||
|
appBar: _AppBar(projectController: projectController),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Obx(() {
|
||||||
|
if (controller.isLoading.value) return buildLoadingSkeleton();
|
||||||
|
final expense = controller.expense.value;
|
||||||
|
if (controller.errorMessage.isNotEmpty || expense == null) {
|
||||||
|
return Center(child: MyText.bodyMedium("No data to display."));
|
||||||
|
}
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_checkPermissionToSubmit(expense);
|
||||||
|
});
|
||||||
|
|
||||||
|
final statusColor = getExpenseStatusColor(expense.status.name,
|
||||||
|
colorCode: expense.status.color);
|
||||||
|
final formattedAmount = formatExpenseAmount(expense.amount);
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
8, 8, 8, 30 + MediaQuery.of(context).padding.bottom),
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 520),
|
||||||
|
child: Card(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10)),
|
||||||
|
elevation: 3,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 14, horizontal: 14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_InvoiceHeader(expense: expense),
|
||||||
|
const Divider(height: 30, thickness: 1.2),
|
||||||
|
_InvoiceParties(expense: expense),
|
||||||
|
const Divider(height: 30, thickness: 1.2),
|
||||||
|
_InvoiceDetailsTable(expense: expense),
|
||||||
|
const Divider(height: 30, thickness: 1.2),
|
||||||
|
_InvoiceDocuments(documents: expense.documents),
|
||||||
|
const Divider(height: 30, thickness: 1.2),
|
||||||
|
_InvoiceTotals(
|
||||||
|
expense: expense,
|
||||||
|
formattedAmount: formattedAmount,
|
||||||
|
statusColor: statusColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
floatingActionButton: Obx(() {
|
||||||
|
if (controller.isLoading.value) return buildLoadingSkeleton();
|
||||||
|
|
||||||
|
final expense = controller.expense.value;
|
||||||
|
if (controller.errorMessage.isNotEmpty || expense == null) {
|
||||||
|
return Center(child: MyText.bodyMedium("No data to display."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_checkedPermission) {
|
||||||
|
_checkedPermission = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_checkPermissionToSubmit(expense);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return FloatingActionButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final editData = {
|
||||||
|
'id': expense.id,
|
||||||
|
'projectName': expense.project.name,
|
||||||
|
'amount': expense.amount,
|
||||||
|
'supplerName': expense.supplerName,
|
||||||
|
'description': expense.description,
|
||||||
|
'transactionId': expense.transactionId,
|
||||||
|
'location': expense.location,
|
||||||
|
'transactionDate': expense.transactionDate,
|
||||||
|
'noOfPersons': expense.noOfPersons,
|
||||||
|
'expensesTypeId': expense.expensesType.id,
|
||||||
|
'paymentModeId': expense.paymentMode.id,
|
||||||
|
'paidById': expense.paidBy.id,
|
||||||
|
'paidByFirstName': expense.paidBy.firstName,
|
||||||
|
'paidByLastName': expense.paidBy.lastName,
|
||||||
|
'attachments': expense.documents
|
||||||
|
.map((doc) => {
|
||||||
|
'url': doc.preSignedUrl,
|
||||||
|
'fileName': doc.fileName,
|
||||||
|
'documentId': doc.documentId,
|
||||||
|
'contentType': doc.contentType,
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
};
|
||||||
|
logSafe('editData: $editData', level: LogLevel.info);
|
||||||
|
|
||||||
|
final addCtrl = Get.put(AddExpenseController());
|
||||||
|
|
||||||
|
await addCtrl.loadMasterData();
|
||||||
|
addCtrl.populateFieldsForEdit(editData);
|
||||||
|
|
||||||
|
await showAddExpenseBottomSheet(isEdit: true);
|
||||||
|
await controller.fetchExpenseDetails();
|
||||||
|
},
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
tooltip: 'Edit Expense',
|
||||||
|
child: const Icon(Icons.edit),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
bottomNavigationBar: Obx(() {
|
||||||
|
final expense = controller.expense.value;
|
||||||
|
if (expense == null) return const SizedBox();
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border(top: BorderSide(color: Color(0x11000000))),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||||
|
child: Wrap(
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
children: expense.nextStatus.where((next) {
|
||||||
|
const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
|
||||||
|
|
||||||
|
final rawPermissions = next.permissionIds;
|
||||||
|
final parsedPermissions =
|
||||||
|
controller.parsePermissionIds(rawPermissions);
|
||||||
|
|
||||||
|
final isSubmitStatus = next.id == submitStatusId;
|
||||||
|
final isCreatedByCurrentUser =
|
||||||
|
employeeInfo?.id == expense.createdBy.id;
|
||||||
|
|
||||||
|
logSafe(
|
||||||
|
'🔐 Permission Logic:\n'
|
||||||
|
'🔸 Status: ${next.name}\n'
|
||||||
|
'🔸 Status ID: ${next.id}\n'
|
||||||
|
'🔸 Parsed Permissions: $parsedPermissions\n'
|
||||||
|
'🔸 Is Submit: $isSubmitStatus\n'
|
||||||
|
'🔸 Created By Current User: $isCreatedByCurrentUser',
|
||||||
|
level: LogLevel.debug,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSubmitStatus) {
|
||||||
|
// Submit can be done ONLY by the creator
|
||||||
|
return isCreatedByCurrentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other statuses - check permission normally
|
||||||
|
return permissionController.hasAnyPermission(parsedPermissions);
|
||||||
|
}).map((next) {
|
||||||
|
return _statusButton(context, controller, expense, next);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _statusButton(BuildContext context, ExpenseDetailController controller,
|
||||||
|
ExpenseDetailModel expense, dynamic next) {
|
||||||
|
Color buttonColor = Colors.red;
|
||||||
|
if (next.color.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
buttonColor = Color(int.parse(next.color.replaceFirst('#', '0xff')));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
DateTime onlyDate(DateTime date) {
|
||||||
|
return DateTime(date.year, date.month, date.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
minimumSize: const Size(100, 40),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||||
|
backgroundColor: buttonColor,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27';
|
||||||
|
if (expense.status.id == reimbursementId) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||||
|
builder: (context) => ReimbursementBottomSheet(
|
||||||
|
expenseId: expense.id,
|
||||||
|
statusId: next.id,
|
||||||
|
onClose: () {},
|
||||||
|
onSubmit: ({
|
||||||
|
required String comment,
|
||||||
|
required String reimburseTransactionId,
|
||||||
|
required String reimburseDate,
|
||||||
|
required String reimburseById,
|
||||||
|
required String statusId,
|
||||||
|
}) async {
|
||||||
|
final transactionDate = DateTime.tryParse(
|
||||||
|
controller.expense.value?.transactionDate ?? '');
|
||||||
|
final selectedReimburseDate =
|
||||||
|
DateTime.tryParse(reimburseDate);
|
||||||
|
final today = DateTime.now();
|
||||||
|
|
||||||
|
if (transactionDate == null ||
|
||||||
|
selectedReimburseDate == null) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Invalid date',
|
||||||
|
message:
|
||||||
|
'Could not parse transaction or reimbursement date.',
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onlyDate(selectedReimburseDate)
|
||||||
|
.isBefore(onlyDate(transactionDate))) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Invalid Date',
|
||||||
|
message:
|
||||||
|
'Reimbursement date cannot be before the transaction date.',
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onlyDate(selectedReimburseDate)
|
||||||
|
.isAfter(onlyDate(today))) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Invalid Date',
|
||||||
|
message: 'Reimbursement date cannot be in the future.',
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final success =
|
||||||
|
await controller.updateExpenseStatusWithReimbursement(
|
||||||
|
comment: comment,
|
||||||
|
reimburseTransactionId: reimburseTransactionId,
|
||||||
|
reimburseDate: reimburseDate,
|
||||||
|
reimburseById: reimburseById,
|
||||||
|
statusId: statusId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Expense reimbursed successfully.',
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
await controller.fetchExpenseDetails();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to reimburse expense.',
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final comment = await showCommentBottomSheet(context, next.name);
|
||||||
|
if (comment == null) return;
|
||||||
|
final success =
|
||||||
|
await controller.updateExpenseStatus(next.id, comment: comment);
|
||||||
|
if (success) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Success',
|
||||||
|
message:
|
||||||
|
'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}',
|
||||||
|
type: SnackbarType.success);
|
||||||
|
await controller.fetchExpenseDetails();
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to update status.',
|
||||||
|
type: SnackbarType.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: MyText.labelMedium(
|
||||||
|
next.displayName.isNotEmpty ? next.displayName : next.name,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||||
|
final ProjectController projectController;
|
||||||
|
const _AppBar({required this.projectController});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppBar(
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
elevation: 1,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new,
|
||||||
|
color: Colors.black, size: 20),
|
||||||
|
onPressed: () => Get.offAllNamed('/dashboard/expense-main-page'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.titleLarge('Expense Details',
|
||||||
|
fontWeight: 700, color: Colors.black),
|
||||||
|
MySpacing.height(2),
|
||||||
|
GetBuilder<ProjectController>(
|
||||||
|
builder: (_) {
|
||||||
|
final projectName =
|
||||||
|
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(
|
||||||
|
projectName,
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvoiceHeader extends StatelessWidget {
|
||||||
|
final ExpenseDetailModel expense;
|
||||||
|
const _InvoiceHeader({required this.expense});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final dateString = DateTimeUtils.convertUtcToLocal(
|
||||||
|
expense.transactionDate.toString(),
|
||||||
|
format: 'dd-MM-yyyy');
|
||||||
|
final statusColor = getExpenseStatusColor(expense.status.name,
|
||||||
|
colorCode: expense.status.color);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||||
|
Row(children: [
|
||||||
|
const Icon(Icons.calendar_month, size: 18, color: Colors.grey),
|
||||||
|
MySpacing.width(6),
|
||||||
|
MyText.bodySmall('Date:', fontWeight: 600),
|
||||||
|
MySpacing.width(6),
|
||||||
|
MyText.bodySmall(dateString, fontWeight: 600),
|
||||||
|
]),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: statusColor.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(8)),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.flag, size: 16, color: statusColor),
|
||||||
|
MySpacing.width(4),
|
||||||
|
MyText.labelSmall(expense.status.name,
|
||||||
|
color: statusColor, fontWeight: 600),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvoiceParties extends StatelessWidget {
|
||||||
|
final ExpenseDetailModel expense;
|
||||||
|
const _InvoiceParties({required this.expense});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
labelValueBlock('Project', expense.project.name),
|
||||||
|
MySpacing.height(16),
|
||||||
|
labelValueBlock('Paid By:',
|
||||||
|
'${expense.paidBy.firstName} ${expense.paidBy.lastName}'),
|
||||||
|
MySpacing.height(16),
|
||||||
|
labelValueBlock('Supplier', expense.supplerName),
|
||||||
|
MySpacing.height(16),
|
||||||
|
labelValueBlock('Created By:',
|
||||||
|
'${expense.createdBy.firstName} ${expense.createdBy.lastName}'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvoiceDetailsTable extends StatelessWidget {
|
||||||
|
final ExpenseDetailModel expense;
|
||||||
|
const _InvoiceDetailsTable({required this.expense});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final transactionDate = DateTimeUtils.convertUtcToLocal(
|
||||||
|
expense.transactionDate.toString(),
|
||||||
|
format: 'dd-MM-yyyy hh:mm a');
|
||||||
|
final createdAt = DateTimeUtils.convertUtcToLocal(
|
||||||
|
expense.createdAt.toString(),
|
||||||
|
format: 'dd-MM-yyyy hh:mm a');
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_detailItem("Expense Type:", expense.expensesType.name),
|
||||||
|
_detailItem("Payment Mode:", expense.paymentMode.name),
|
||||||
|
_detailItem("Transaction Date:", transactionDate),
|
||||||
|
_detailItem("Created At:", createdAt),
|
||||||
|
_detailItem("Pre-Approved:", expense.preApproved ? 'Yes' : 'No'),
|
||||||
|
_detailItem("Description:",
|
||||||
|
expense.description.trim().isNotEmpty ? expense.description : '-',
|
||||||
|
isDescription: true),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _detailItem(String title, String value,
|
||||||
|
{bool isDescription = false}) =>
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodySmall(title, fontWeight: 600),
|
||||||
|
MySpacing.height(3),
|
||||||
|
isDescription
|
||||||
|
? ExpandableDescription(description: value)
|
||||||
|
: MyText.bodySmall(value, fontWeight: 500),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvoiceDocuments extends StatelessWidget {
|
||||||
|
final List<ExpenseDocument> documents;
|
||||||
|
const _InvoiceDocuments({required this.documents});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (documents.isEmpty)
|
||||||
|
return MyText.bodyMedium('No Supporting Documents', color: Colors.grey);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodySmall("Supporting Documents:", fontWeight: 600),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: documents.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final doc = documents[index];
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final imageDocs = documents
|
||||||
|
.where((d) => d.contentType.startsWith('image/'))
|
||||||
|
.toList();
|
||||||
|
final initialIndex =
|
||||||
|
imageDocs.indexWhere((d) => d.documentId == doc.documentId);
|
||||||
|
if (imageDocs.isNotEmpty && initialIndex != -1) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => ImageViewerDialog(
|
||||||
|
imageSources:
|
||||||
|
imageDocs.map((e) => e.preSignedUrl).toList(),
|
||||||
|
initialIndex: initialIndex,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final Uri url = Uri.parse(doc.preSignedUrl);
|
||||||
|
if (await canLaunchUrl(url)) {
|
||||||
|
await launchUrl(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: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
doc.contentType.startsWith('image/')
|
||||||
|
? Icons.image
|
||||||
|
: Icons.insert_drive_file,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 7),
|
||||||
|
Expanded(
|
||||||
|
child: MyText.labelSmall(
|
||||||
|
doc.fileName,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExpensePermissionHelper {
|
||||||
|
static bool canEditExpense(
|
||||||
|
EmployeeInfo? employee, ExpenseDetailModel expense) {
|
||||||
|
return employee?.id == expense.createdBy.id &&
|
||||||
|
_isInAllowedEditStatus(expense.status.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool canSubmitExpense(
|
||||||
|
EmployeeInfo? employee, ExpenseDetailModel expense) {
|
||||||
|
return employee?.id == expense.createdBy.id &&
|
||||||
|
expense.nextStatus.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isInAllowedEditStatus(String statusId) {
|
||||||
|
const editableStatusIds = [
|
||||||
|
"d1ee5eec-24b6-4364-8673-a8f859c60729",
|
||||||
|
"965eda62-7907-4963-b4a1-657fb0b2724b",
|
||||||
|
"297e0d8f-f668-41b5-bfea-e03b354251c8"
|
||||||
|
];
|
||||||
|
return editableStatusIds.contains(statusId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvoiceTotals extends StatelessWidget {
|
||||||
|
final ExpenseDetailModel expense;
|
||||||
|
final String formattedAmount;
|
||||||
|
final Color statusColor;
|
||||||
|
const _InvoiceTotals({
|
||||||
|
required this.expense,
|
||||||
|
required this.formattedAmount,
|
||||||
|
required this.statusColor,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
MyText.bodyLarge("Total:", fontWeight: 700),
|
||||||
|
const Spacer(),
|
||||||
|
MyText.bodyLarge(formattedAmount, fontWeight: 700),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
404
lib/view/expense/expense_filter_bottom_sheet.dart
Normal file
404
lib/view/expense/expense_filter_bottom_sheet.dart
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.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/helpers/widgets/my_text_style.dart';
|
||||||
|
import 'package:marco/model/employee_model.dart';
|
||||||
|
import 'package:marco/model/expense/employee_selector_for_filter_bottom_sheet.dart';
|
||||||
|
|
||||||
|
class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||||
|
final ExpenseController expenseController;
|
||||||
|
final ScrollController scrollController;
|
||||||
|
|
||||||
|
const ExpenseFilterBottomSheet({
|
||||||
|
super.key,
|
||||||
|
required this.expenseController,
|
||||||
|
required this.scrollController,
|
||||||
|
});
|
||||||
|
|
||||||
|
// FIX: create search adapter
|
||||||
|
Future<List<EmployeeModel>> searchEmployeesForBottomSheet(
|
||||||
|
String query) async {
|
||||||
|
await expenseController
|
||||||
|
.searchEmployees(query); // async method, returns void
|
||||||
|
return expenseController.employeeSearchResults.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(() {
|
||||||
|
return BaseBottomSheet(
|
||||||
|
title: 'Filter Expenses',
|
||||||
|
onCancel: () => Get.back(),
|
||||||
|
onSubmit: () {
|
||||||
|
expenseController.fetchExpenses();
|
||||||
|
Get.back();
|
||||||
|
},
|
||||||
|
submitText: 'Submit',
|
||||||
|
submitColor: Colors.indigo,
|
||||||
|
submitIcon: Icons.check_circle_outline,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
controller: scrollController,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => expenseController.clearFilters(),
|
||||||
|
child: MyText(
|
||||||
|
"Reset Filter",
|
||||||
|
style: MyTextStyle.labelMedium(
|
||||||
|
color: Colors.red,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
_buildProjectFilter(context),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_buildStatusFilter(context),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_buildDateRangeFilter(context),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_buildPaidByFilter(context),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_buildCreatedByFilter(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildField(String label, Widget child) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.labelMedium(label),
|
||||||
|
MySpacing.height(8),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProjectFilter(BuildContext context) {
|
||||||
|
return _buildField(
|
||||||
|
"Project",
|
||||||
|
_popupSelector(
|
||||||
|
context,
|
||||||
|
currentValue: expenseController.selectedProject.value.isEmpty
|
||||||
|
? 'Select Project'
|
||||||
|
: expenseController.selectedProject.value,
|
||||||
|
items: expenseController.globalProjects,
|
||||||
|
onSelected: (value) => expenseController.selectedProject.value = value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusFilter(BuildContext context) {
|
||||||
|
return _buildField(
|
||||||
|
"Expense Status",
|
||||||
|
_popupSelector(
|
||||||
|
context,
|
||||||
|
currentValue: expenseController.selectedStatus.value.isEmpty
|
||||||
|
? 'Select Expense Status'
|
||||||
|
: expenseController.expenseStatuses
|
||||||
|
.firstWhereOrNull(
|
||||||
|
(e) => e.id == expenseController.selectedStatus.value)
|
||||||
|
?.name ??
|
||||||
|
'Select Expense Status',
|
||||||
|
items: expenseController.expenseStatuses.map((e) => e.name).toList(),
|
||||||
|
onSelected: (name) {
|
||||||
|
final status = expenseController.expenseStatuses
|
||||||
|
.firstWhere((e) => e.name == name);
|
||||||
|
expenseController.selectedStatus.value = status.id;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDateRangeFilter(BuildContext context) {
|
||||||
|
return _buildField(
|
||||||
|
"Date Filter",
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Obx(() {
|
||||||
|
return SegmentedButton<String>(
|
||||||
|
segments: expenseController.dateTypes
|
||||||
|
.map(
|
||||||
|
(type) => ButtonSegment(
|
||||||
|
value: type,
|
||||||
|
label: MyText(
|
||||||
|
type,
|
||||||
|
style: MyTextStyle.bodySmall(
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
selected: {expenseController.selectedDateType.value},
|
||||||
|
onSelectionChanged: (newSelection) {
|
||||||
|
if (newSelection.isNotEmpty) {
|
||||||
|
expenseController.selectedDateType.value = newSelection.first;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: ButtonStyle(
|
||||||
|
visualDensity:
|
||||||
|
const VisualDensity(horizontal: -2, vertical: -2),
|
||||||
|
padding: MaterialStateProperty.all(
|
||||||
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
),
|
||||||
|
backgroundColor: MaterialStateProperty.resolveWith(
|
||||||
|
(states) => states.contains(MaterialState.selected)
|
||||||
|
? Colors.indigo.shade100
|
||||||
|
: Colors.grey.shade100,
|
||||||
|
),
|
||||||
|
foregroundColor: MaterialStateProperty.resolveWith(
|
||||||
|
(states) => states.contains(MaterialState.selected)
|
||||||
|
? Colors.indigo
|
||||||
|
: Colors.black87,
|
||||||
|
),
|
||||||
|
shape: MaterialStateProperty.all(
|
||||||
|
RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
side: MaterialStateProperty.resolveWith(
|
||||||
|
(states) => BorderSide(
|
||||||
|
color: states.contains(MaterialState.selected)
|
||||||
|
? Colors.indigo
|
||||||
|
: Colors.grey.shade300,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
MySpacing.height(16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _dateButton(
|
||||||
|
label: expenseController.startDate.value == null
|
||||||
|
? 'Start Date'
|
||||||
|
: DateTimeUtils.formatDate(
|
||||||
|
expenseController.startDate.value!, 'dd MMM yyyy'),
|
||||||
|
onTap: () => _selectDate(
|
||||||
|
context,
|
||||||
|
expenseController.startDate,
|
||||||
|
lastDate: expenseController.endDate.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.width(12),
|
||||||
|
Expanded(
|
||||||
|
child: _dateButton(
|
||||||
|
label: expenseController.endDate.value == null
|
||||||
|
? 'End Date'
|
||||||
|
: DateTimeUtils.formatDate(
|
||||||
|
expenseController.endDate.value!, 'dd MMM yyyy'),
|
||||||
|
onTap: () => _selectDate(
|
||||||
|
context,
|
||||||
|
expenseController.endDate,
|
||||||
|
firstDate: expenseController.startDate.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPaidByFilter(BuildContext context) {
|
||||||
|
return _buildField(
|
||||||
|
"Paid By",
|
||||||
|
_employeeSelector(
|
||||||
|
context: context,
|
||||||
|
selectedEmployees: expenseController.selectedPaidByEmployees,
|
||||||
|
searchEmployees: searchEmployeesForBottomSheet, // FIXED
|
||||||
|
title: 'Search Paid By',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCreatedByFilter(BuildContext context) {
|
||||||
|
return _buildField(
|
||||||
|
"Created By",
|
||||||
|
_employeeSelector(
|
||||||
|
context: context,
|
||||||
|
selectedEmployees: expenseController.selectedCreatedByEmployees,
|
||||||
|
searchEmployees: searchEmployeesForBottomSheet, // FIXED
|
||||||
|
title: 'Search Created By',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectDate(
|
||||||
|
BuildContext context,
|
||||||
|
Rx<DateTime?> dateNotifier, {
|
||||||
|
DateTime? firstDate,
|
||||||
|
DateTime? lastDate,
|
||||||
|
}) async {
|
||||||
|
final DateTime? picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: dateNotifier.value ?? DateTime.now(),
|
||||||
|
firstDate: firstDate ?? DateTime(2020),
|
||||||
|
lastDate: lastDate ?? DateTime.now().add(const Duration(days: 365)),
|
||||||
|
);
|
||||||
|
if (picked != null && picked != dateNotifier.value) {
|
||||||
|
dateNotifier.value = picked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _popupSelector(
|
||||||
|
BuildContext context, {
|
||||||
|
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: MySpacing.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: MyText(
|
||||||
|
currentValue,
|
||||||
|
style: const TextStyle(color: Colors.black87),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.arrow_drop_down, color: Colors.grey),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dateButton({required String label, required VoidCallback onTap}) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: MySpacing.xy(16, 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
|
||||||
|
MySpacing.width(8),
|
||||||
|
Expanded(
|
||||||
|
child: MyText(
|
||||||
|
label,
|
||||||
|
style: MyTextStyle.bodyMedium(),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showEmployeeSelectorBottomSheet({
|
||||||
|
required BuildContext context,
|
||||||
|
required RxList<EmployeeModel> selectedEmployees,
|
||||||
|
required Future<List<EmployeeModel>> Function(String) searchEmployees,
|
||||||
|
String title = 'Select Employee',
|
||||||
|
}) async {
|
||||||
|
final List<EmployeeModel>? result =
|
||||||
|
await showModalBottomSheet<List<EmployeeModel>>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (context) => EmployeeSelectorBottomSheet(
|
||||||
|
selectedEmployees: selectedEmployees,
|
||||||
|
searchEmployees: searchEmployees,
|
||||||
|
title: title,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result != null) {
|
||||||
|
selectedEmployees.assignAll(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _employeeSelector({
|
||||||
|
required BuildContext context,
|
||||||
|
required RxList<EmployeeModel> selectedEmployees,
|
||||||
|
required Future<List<EmployeeModel>> Function(String) searchEmployees,
|
||||||
|
String title = 'Search Employee',
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Obx(() {
|
||||||
|
if (selectedEmployees.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: selectedEmployees
|
||||||
|
.map((emp) => Chip(
|
||||||
|
label: MyText(emp.name),
|
||||||
|
onDeleted: () => selectedEmployees.remove(emp),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
MySpacing.height(8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _showEmployeeSelectorBottomSheet(
|
||||||
|
context: context,
|
||||||
|
selectedEmployees: selectedEmployees,
|
||||||
|
searchEmployees: searchEmployees,
|
||||||
|
title: title,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
padding: MySpacing.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.search, color: Colors.grey),
|
||||||
|
MySpacing.width(8),
|
||||||
|
Expanded(child: MyText(title)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
lib/view/expense/expense_screen.dart
Normal file
140
lib/view/expense/expense_screen.dart
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
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/controller/permission_controller.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||||
|
import 'package:marco/model/expense/expense_list_model.dart';
|
||||||
|
import 'package:marco/model/expense/add_expense_bottom_sheet.dart';
|
||||||
|
import 'package:marco/view/expense/expense_filter_bottom_sheet.dart';
|
||||||
|
import 'package:marco/helpers/widgets/expense_main_components.dart';
|
||||||
|
import 'package:marco/helpers/utils/permission_constants.dart';
|
||||||
|
|
||||||
|
class ExpenseMainScreen extends StatefulWidget {
|
||||||
|
const ExpenseMainScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ExpenseMainScreen> createState() => _ExpenseMainScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
|
||||||
|
bool isHistoryView = false;
|
||||||
|
final searchController = TextEditingController();
|
||||||
|
final expenseController = Get.put(ExpenseController());
|
||||||
|
final projectController = Get.find<ProjectController>();
|
||||||
|
final permissionController = Get.find<PermissionController>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
expenseController.fetchExpenses();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refreshExpenses() => expenseController.fetchExpenses();
|
||||||
|
|
||||||
|
void _openFilterBottomSheet() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => ExpenseFilterBottomSheet(
|
||||||
|
expenseController: expenseController,
|
||||||
|
scrollController: ScrollController(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ExpenseModel> _getFilteredExpenses() {
|
||||||
|
final query = searchController.text.trim().toLowerCase();
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
final filtered = expenseController.expenses.where((e) {
|
||||||
|
return query.isEmpty ||
|
||||||
|
e.expensesType.name.toLowerCase().contains(query) ||
|
||||||
|
e.supplerName.toLowerCase().contains(query) ||
|
||||||
|
e.paymentMode.name.toLowerCase().contains(query);
|
||||||
|
}).toList()
|
||||||
|
..sort((a, b) => b.transactionDate.compareTo(a.transactionDate));
|
||||||
|
|
||||||
|
return isHistoryView
|
||||||
|
? filtered
|
||||||
|
.where((e) =>
|
||||||
|
e.transactionDate.isBefore(DateTime(now.year, now.month)))
|
||||||
|
.toList()
|
||||||
|
: filtered
|
||||||
|
.where((e) =>
|
||||||
|
e.transactionDate.month == now.month &&
|
||||||
|
e.transactionDate.year == now.year)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
appBar: ExpenseAppBar(projectController: projectController),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SearchAndFilter(
|
||||||
|
controller: searchController,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
onFilterTap: _openFilterBottomSheet,
|
||||||
|
onRefreshTap: _refreshExpenses,
|
||||||
|
expenseController: expenseController,
|
||||||
|
),
|
||||||
|
ToggleButtonsRow(
|
||||||
|
isHistoryView: isHistoryView,
|
||||||
|
onToggle: (v) => setState(() => isHistoryView = v),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Obx(() {
|
||||||
|
if (expenseController.isLoading.value &&
|
||||||
|
expenseController.expenses.isEmpty) {
|
||||||
|
return SkeletonLoaders.expenseListSkeletonLoader();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expenseController.errorMessage.isNotEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: MyText.bodyMedium(
|
||||||
|
expenseController.errorMessage.value,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final filteredList = _getFilteredExpenses();
|
||||||
|
|
||||||
|
return NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: (ScrollNotification scrollInfo) {
|
||||||
|
if (scrollInfo.metrics.pixels ==
|
||||||
|
scrollInfo.metrics.maxScrollExtent &&
|
||||||
|
!expenseController.isLoading.value) {
|
||||||
|
expenseController.loadMoreExpenses();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: ExpenseList(
|
||||||
|
expenseList: filteredList,
|
||||||
|
onViewDetail: () => expenseController.fetchExpenses(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ✅ FAB only if user has expenseUpload permission
|
||||||
|
floatingActionButton:
|
||||||
|
permissionController.hasPermission(Permissions.expenseUpload)
|
||||||
|
? FloatingActionButton(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
onPressed: showAddExpenseBottomSheet,
|
||||||
|
child: const Icon(Icons.add, color: Colors.white),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,6 +26,23 @@ class _LayoutState extends State<Layout> {
|
|||||||
final bool isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage");
|
final bool isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage");
|
||||||
final projectController = Get.find<ProjectController>();
|
final projectController = Get.find<ProjectController>();
|
||||||
|
|
||||||
|
bool hasMpin = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_checkMpinStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkMpinStatus() async {
|
||||||
|
final bool mpinStatus = await LocalStorage.getIsMpin();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
hasMpin = mpinStatus;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MyResponsive(builder: (context, _, screenMT) {
|
return MyResponsive(builder: (context, _, screenMT) {
|
||||||
@ -43,7 +60,7 @@ class _LayoutState extends State<Layout> {
|
|||||||
Widget _buildScaffold(BuildContext context, {bool isMobile = false}) {
|
Widget _buildScaffold(BuildContext context, {bool isMobile = false}) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
key: controller.scaffoldKey,
|
key: controller.scaffoldKey,
|
||||||
endDrawer: UserProfileBar(),
|
endDrawer: const UserProfileBar(),
|
||||||
floatingActionButton: widget.floatingActionButton,
|
floatingActionButton: widget.floatingActionButton,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
@ -68,28 +85,7 @@ class _LayoutState extends State<Layout> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Obx(() {
|
_buildProjectDropdown(context, isMobile),
|
||||||
if (!projectController.isProjectSelectionExpanded.value) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
return Positioned(
|
|
||||||
top: 95,
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
child: Material(
|
|
||||||
elevation: 4,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
child: _buildProjectList(context, isMobile),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -97,6 +93,7 @@ class _LayoutState extends State<Layout> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Header Section
|
||||||
Widget _buildHeader(BuildContext context, bool isMobile) {
|
Widget _buildHeader(BuildContext context, bool isMobile) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
@ -147,46 +144,65 @@ class _LayoutState extends State<Layout> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: hasProjects
|
child: hasProjects
|
||||||
? GestureDetector(
|
? (projectController.projects.length > 1
|
||||||
onTap: () => projectController
|
? GestureDetector(
|
||||||
.isProjectSelectionExpanded
|
onTap: () => projectController
|
||||||
.toggle(),
|
.isProjectSelectionExpanded
|
||||||
child: Column(
|
.toggle(),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment:
|
||||||
Row(
|
CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Row(
|
||||||
child: Row(
|
children: [
|
||||||
children: [
|
Expanded(
|
||||||
Expanded(
|
child: Row(
|
||||||
child: MyText.bodyLarge(
|
children: [
|
||||||
selectedProject?.name ??
|
Expanded(
|
||||||
"Select Project",
|
child: MyText.bodyLarge(
|
||||||
fontWeight: 700,
|
selectedProject?.name ??
|
||||||
maxLines: 1,
|
"Select Project",
|
||||||
overflow: TextOverflow.ellipsis,
|
fontWeight: 700,
|
||||||
),
|
maxLines: 1,
|
||||||
|
overflow:
|
||||||
|
TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
isExpanded
|
||||||
|
? Icons
|
||||||
|
.arrow_drop_up_outlined
|
||||||
|
: Icons
|
||||||
|
.arrow_drop_down_outlined,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Icon(
|
),
|
||||||
isExpanded
|
],
|
||||||
? Icons.arrow_drop_up_outlined
|
),
|
||||||
: Icons
|
MyText.bodyMedium(
|
||||||
.arrow_drop_down_outlined,
|
"Hi, ${employeeInfo?.firstName ?? ''}",
|
||||||
color: Colors.black,
|
color: Colors.black54,
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
MyText.bodyMedium(
|
)
|
||||||
"Hi, ${employeeInfo?.firstName ?? ''}",
|
: Column(
|
||||||
color: Colors.black54,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
),
|
children: [
|
||||||
],
|
MyText.bodyLarge(
|
||||||
),
|
selectedProject?.name ?? "No Project",
|
||||||
)
|
fontWeight: 700,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
MyText.bodyMedium(
|
||||||
|
"Hi, ${employeeInfo?.firstName ?? ''}",
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
))
|
||||||
: Column(
|
: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -217,11 +233,32 @@ class _LayoutState extends State<Layout> {
|
|||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
Stack(
|
||||||
icon: const Icon(Icons.menu),
|
clipBehavior: Clip.none,
|
||||||
onPressed: () =>
|
alignment: Alignment.center,
|
||||||
controller.scaffoldKey.currentState?.openEndDrawer(),
|
children: [
|
||||||
),
|
IconButton(
|
||||||
|
icon: const Icon(Icons.menu),
|
||||||
|
onPressed: () => controller.scaffoldKey.currentState
|
||||||
|
?.openEndDrawer(),
|
||||||
|
),
|
||||||
|
if (!hasMpin)
|
||||||
|
Positioned(
|
||||||
|
right: 10,
|
||||||
|
top: 10,
|
||||||
|
child: Container(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.redAccent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border:
|
||||||
|
Border.all(color: Colors.white, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -243,6 +280,7 @@ class _LayoutState extends State<Layout> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loading Skeleton for Header
|
||||||
Widget _buildLoadingSkeleton() {
|
Widget _buildLoadingSkeleton() {
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
@ -293,6 +331,32 @@ class _LayoutState extends State<Layout> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Project List Popup
|
||||||
|
Widget _buildProjectDropdown(BuildContext context, bool isMobile) {
|
||||||
|
return Obx(() {
|
||||||
|
if (!projectController.isProjectSelectionExpanded.value) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Positioned(
|
||||||
|
top: 95,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: Material(
|
||||||
|
elevation: 4,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
child: _buildProjectList(context, isMobile),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildProjectList(BuildContext context, bool isMobile) {
|
Widget _buildProjectList(BuildContext context, bool isMobile) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|||||||
156
lib/view/layouts/offline_screen.dart
Normal file
156
lib/view/layouts/offline_screen.dart
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||||
|
import 'package:marco/images.dart';
|
||||||
|
|
||||||
|
class OfflineScreen extends StatefulWidget {
|
||||||
|
const OfflineScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<OfflineScreen> createState() => _OfflineScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OfflineScreenState extends State<OfflineScreen>
|
||||||
|
with SingleTickerProviderStateMixin, UIMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _logoAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 800),
|
||||||
|
);
|
||||||
|
_logoAnimation = CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeOutBack,
|
||||||
|
);
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
_RedWaveBackground(brandRed: contentTheme.brandRed),
|
||||||
|
SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
ScaleTransition(
|
||||||
|
scale: _logoAnimation,
|
||||||
|
child: Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black12,
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Image.asset(Images.logoDark),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Increased spacing here
|
||||||
|
const SizedBox(height: 120),
|
||||||
|
const Icon(Icons.wifi_off,
|
||||||
|
size: 100, color: Colors.redAccent),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text(
|
||||||
|
"No Internet Connection",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 15),
|
||||||
|
const Text(
|
||||||
|
"It seems you're currently offline. Please check your network settings or Wi-Fi connection.",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 16, color: Colors.grey),
|
||||||
|
),
|
||||||
|
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RedWaveBackground extends StatelessWidget {
|
||||||
|
final Color brandRed;
|
||||||
|
const _RedWaveBackground({required this.brandRed});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CustomPaint(
|
||||||
|
painter: _WavePainter(brandRed),
|
||||||
|
size: Size.infinite,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WavePainter extends CustomPainter {
|
||||||
|
final Color brandRed;
|
||||||
|
|
||||||
|
_WavePainter(this.brandRed);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint1 = Paint()
|
||||||
|
..shader = LinearGradient(
|
||||||
|
colors: [brandRed, const Color.fromARGB(255, 97, 22, 22)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
|
||||||
|
|
||||||
|
final path1 = Path()
|
||||||
|
..moveTo(0, size.height * 0.2)
|
||||||
|
..quadraticBezierTo(size.width * 0.25, size.height * 0.05,
|
||||||
|
size.width * 0.5, size.height * 0.15)
|
||||||
|
..quadraticBezierTo(
|
||||||
|
size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1)
|
||||||
|
..lineTo(size.width, 0)
|
||||||
|
..lineTo(0, 0)
|
||||||
|
..close();
|
||||||
|
|
||||||
|
canvas.drawPath(path1, paint1);
|
||||||
|
|
||||||
|
final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15);
|
||||||
|
final path2 = Path()
|
||||||
|
..moveTo(0, size.height * 0.25)
|
||||||
|
..quadraticBezierTo(
|
||||||
|
size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2)
|
||||||
|
..lineTo(size.width, 0)
|
||||||
|
..lineTo(0, 0)
|
||||||
|
..close();
|
||||||
|
|
||||||
|
canvas.drawPath(path2, paint2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
@ -9,6 +9,8 @@ import 'package:marco/helpers/widgets/my_text.dart';
|
|||||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||||
import 'package:marco/model/employee_info.dart';
|
import 'package:marco/model/employee_info.dart';
|
||||||
import 'package:marco/helpers/widgets/avatar.dart';
|
import 'package:marco/helpers/widgets/avatar.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/controller/auth/mpin_controller.dart';
|
||||||
|
|
||||||
class UserProfileBar extends StatefulWidget {
|
class UserProfileBar extends StatefulWidget {
|
||||||
final bool isCondensed;
|
final bool isCondensed;
|
||||||
@ -24,11 +26,13 @@ class _UserProfileBarState extends State<UserProfileBar>
|
|||||||
final ThemeCustomizer customizer = ThemeCustomizer.instance;
|
final ThemeCustomizer customizer = ThemeCustomizer.instance;
|
||||||
bool isCondensed = false;
|
bool isCondensed = false;
|
||||||
EmployeeInfo? employeeInfo;
|
EmployeeInfo? employeeInfo;
|
||||||
|
bool hasMpin = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadEmployeeInfo();
|
_loadEmployeeInfo();
|
||||||
|
_checkMpinStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadEmployeeInfo() {
|
void _loadEmployeeInfo() {
|
||||||
@ -37,6 +41,13 @@ class _UserProfileBarState extends State<UserProfileBar>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _checkMpinStatus() async {
|
||||||
|
final bool mpinStatus = await LocalStorage.getIsMpin();
|
||||||
|
setState(() {
|
||||||
|
hasMpin = mpinStatus;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
isCondensed = widget.isCondensed;
|
isCondensed = widget.isCondensed;
|
||||||
@ -44,19 +55,23 @@ class _UserProfileBarState extends State<UserProfileBar>
|
|||||||
return MyCard(
|
return MyCard(
|
||||||
borderRadiusAll: 16,
|
borderRadiusAll: 16,
|
||||||
paddingAll: 0,
|
paddingAll: 0,
|
||||||
shadow: MyShadow(position: MyShadowPosition.centerRight, elevation: 4),
|
shadow: MyShadow(
|
||||||
|
position: MyShadowPosition.centerRight,
|
||||||
|
elevation: 6,
|
||||||
|
blurRadius: 12,
|
||||||
|
),
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
leftBarTheme.background.withOpacity(0.95),
|
leftBarTheme.background.withOpacity(0.97),
|
||||||
leftBarTheme.background.withOpacity(0.85),
|
leftBarTheme.background.withOpacity(0.88),
|
||||||
],
|
],
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
width: isCondensed ? 90 : 250,
|
width: isCondensed ? 90 : 260,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
@ -65,9 +80,10 @@ class _UserProfileBarState extends State<UserProfileBar>
|
|||||||
left: false,
|
left: false,
|
||||||
right: false,
|
right: false,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
userProfileSection(),
|
userProfileSection(),
|
||||||
MySpacing.height(8),
|
MySpacing.height(16),
|
||||||
supportAndSettingsMenu(),
|
supportAndSettingsMenu(),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
logoutButton(),
|
logoutButton(),
|
||||||
@ -78,17 +94,18 @@ class _UserProfileBarState extends State<UserProfileBar>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// User Profile Section - Avatar + Name
|
||||||
Widget userProfileSection() {
|
Widget userProfileSection() {
|
||||||
if (employeeInfo == null) {
|
if (employeeInfo == null) {
|
||||||
return const Padding(
|
return const Padding(
|
||||||
padding: EdgeInsets.all(24.0),
|
padding: EdgeInsets.symmetric(vertical: 32),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: MySpacing.xy(58, 68),
|
padding: MySpacing.fromLTRB(20, 50, 30, 50),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: leftBarTheme.activeItemBackground,
|
color: leftBarTheme.activeItemBackground,
|
||||||
borderRadius: const BorderRadius.only(
|
borderRadius: const BorderRadius.only(
|
||||||
@ -96,55 +113,102 @@ class _UserProfileBarState extends State<UserProfileBar>
|
|||||||
topRight: Radius.circular(16),
|
topRight: Radius.circular(16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Avatar(
|
Avatar(
|
||||||
firstName: employeeInfo?.firstName ?? 'First',
|
firstName: employeeInfo?.firstName ?? 'F',
|
||||||
lastName: employeeInfo?.lastName ?? 'Name',
|
lastName: employeeInfo?.lastName ?? 'N',
|
||||||
size: 60,
|
size: 50,
|
||||||
),
|
),
|
||||||
MySpacing.height(12),
|
MySpacing.width(12),
|
||||||
MyText.labelLarge(
|
Expanded(
|
||||||
"${employeeInfo?.firstName ?? 'First'} ${employeeInfo?.lastName ?? 'Last'}",
|
child: Column(
|
||||||
fontWeight: 700,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
color: leftBarTheme.activeItemColor,
|
children: [
|
||||||
textAlign: TextAlign.center,
|
MyText.bodyMedium(
|
||||||
|
"${employeeInfo?.firstName ?? 'First'} ${employeeInfo?.lastName ?? 'Last'}",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: leftBarTheme.activeItemColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Menu Section with Settings, Support & MPIN
|
||||||
Widget supportAndSettingsMenu() {
|
Widget supportAndSettingsMenu() {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: MySpacing.xy(16, 16),
|
padding: MySpacing.xy(16, 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
menuItem(icon: LucideIcons.settings, label: "Settings"),
|
menuItem(
|
||||||
MySpacing.height(12),
|
icon: LucideIcons.settings,
|
||||||
menuItem(icon: LucideIcons.badge_help, label: "Support"),
|
label: "Settings",
|
||||||
|
),
|
||||||
|
MySpacing.height(14),
|
||||||
|
menuItem(
|
||||||
|
icon: LucideIcons.badge_help,
|
||||||
|
label: "Support",
|
||||||
|
),
|
||||||
|
MySpacing.height(14),
|
||||||
|
menuItem(
|
||||||
|
icon: LucideIcons.lock,
|
||||||
|
label: hasMpin ? "Change MPIN" : "Set MPIN",
|
||||||
|
iconColor: hasMpin ? leftBarTheme.onBackground : Colors.redAccent,
|
||||||
|
labelColor: hasMpin ? leftBarTheme.onBackground : Colors.redAccent,
|
||||||
|
onTap: () {
|
||||||
|
final controller = Get.put(MPINController());
|
||||||
|
if (hasMpin) {
|
||||||
|
controller.setChangeMpinMode();
|
||||||
|
}
|
||||||
|
Navigator.pushNamed(context, "/auth/mpin-auth");
|
||||||
|
},
|
||||||
|
filled: true,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget menuItem({required IconData icon, required String label}) {
|
Widget menuItem({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
Color? iconColor,
|
||||||
|
Color? labelColor,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
bool filled = false,
|
||||||
|
}) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {},
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(12),
|
||||||
hoverColor: leftBarTheme.activeItemBackground.withOpacity(0.2),
|
hoverColor: leftBarTheme.activeItemBackground.withOpacity(0.25),
|
||||||
splashColor: leftBarTheme.activeItemBackground.withOpacity(0.3),
|
splashColor: leftBarTheme.activeItemBackground.withOpacity(0.35),
|
||||||
child: Padding(
|
child: Container(
|
||||||
padding: MySpacing.xy(12, 10),
|
padding: MySpacing.xy(14, 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: filled
|
||||||
|
? leftBarTheme.activeItemBackground.withOpacity(0.15)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: filled
|
||||||
|
? leftBarTheme.activeItemBackground.withOpacity(0.3)
|
||||||
|
: Colors.transparent,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 20, color: leftBarTheme.onBackground),
|
Icon(icon, size: 22, color: iconColor ?? leftBarTheme.onBackground),
|
||||||
MySpacing.width(12),
|
MySpacing.width(14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: MyText.bodyMedium(
|
child: MyText.bodyMedium(
|
||||||
label,
|
label,
|
||||||
color: leftBarTheme.onBackground,
|
color: labelColor ?? leftBarTheme.onBackground,
|
||||||
fontWeight: 500,
|
fontWeight: 600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -153,142 +217,19 @@ class _UserProfileBarState extends State<UserProfileBar>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Logout Button
|
||||||
Widget logoutButton() {
|
Widget logoutButton() {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
bool? confirm = await showDialog<bool>(
|
await _showLogoutConfirmation();
|
||||||
context: context,
|
|
||||||
builder: (context) {
|
|
||||||
return Dialog(
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
LucideIcons.log_out,
|
|
||||||
size: 48,
|
|
||||||
color: Colors.redAccent,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
"Logout Confirmation",
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: Theme.of(context).colorScheme.onBackground,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
"Are you sure you want to logout?\nYou will need to login again to continue.",
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onSurface
|
|
||||||
.withOpacity(0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, false),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: Colors.grey.shade700,
|
|
||||||
),
|
|
||||||
child: const Text("Cancel"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () async {
|
|
||||||
await LocalStorage.logout();
|
|
||||||
},
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.redAccent,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Text("Logout"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirm == true) {
|
|
||||||
// Show animated loader dialog
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (context) => Dialog(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: const [
|
|
||||||
CircularProgressIndicator(),
|
|
||||||
SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
"Logging you out...",
|
|
||||||
style: TextStyle(color: Colors.white),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await LocalStorage.logout();
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
Navigator.pop(context);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(LucideIcons.check, color: Colors.green),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
const Text("You’ve been logged out successfully."),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
backgroundColor: Colors.grey.shade900,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
duration: const Duration(seconds: 3),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
borderRadius: const BorderRadius.only(
|
borderRadius: const BorderRadius.only(
|
||||||
bottomLeft: Radius.circular(16),
|
bottomLeft: Radius.circular(16),
|
||||||
bottomRight: Radius.circular(16),
|
bottomRight: Radius.circular(16),
|
||||||
),
|
),
|
||||||
hoverColor: leftBarTheme.activeItemBackground.withOpacity(0.2),
|
hoverColor: leftBarTheme.activeItemBackground.withOpacity(0.25),
|
||||||
splashColor: leftBarTheme.activeItemBackground.withOpacity(0.3),
|
splashColor: leftBarTheme.activeItemBackground.withOpacity(0.35),
|
||||||
child: AnimatedContainer(
|
child: Container(
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
width: double.infinity,
|
|
||||||
padding: MySpacing.all(16),
|
padding: MySpacing.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: leftBarTheme.activeItemBackground,
|
color: leftBarTheme.activeItemBackground,
|
||||||
@ -316,4 +257,78 @@ class _UserProfileBarState extends State<UserProfileBar>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showLogoutConfirmation() async {
|
||||||
|
bool? confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _buildLogoutDialog(context),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirm == true) {
|
||||||
|
await LocalStorage.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLogoutDialog(BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.log_out, size: 48, color: Colors.redAccent),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
"Logout Confirmation",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Theme.of(context).colorScheme.onBackground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
"Are you sure you want to logout?\nYou will need to login again to continue.",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.redAccent,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text("Logout"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,7 +138,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
|||||||
MySpacing.height(flexSpacing),
|
MySpacing.height(flexSpacing),
|
||||||
_buildActionBar(),
|
_buildActionBar(),
|
||||||
Padding(
|
Padding(
|
||||||
padding: MySpacing.x(flexSpacing),
|
padding: MySpacing.x(8),
|
||||||
child: _buildDailyProgressReportTab(),
|
child: _buildDailyProgressReportTab(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -158,9 +158,8 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
|||||||
children: [
|
children: [
|
||||||
_buildActionItem(
|
_buildActionItem(
|
||||||
label: "Filter",
|
label: "Filter",
|
||||||
icon: Icons.filter_list_alt,
|
icon: Icons.tune,
|
||||||
tooltip: 'Filter Project',
|
tooltip: 'Filter Project',
|
||||||
color: Colors.blueAccent,
|
|
||||||
onTap: _openFilterSheet,
|
onTap: _openFilterSheet,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@ -181,7 +180,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
|||||||
required IconData icon,
|
required IconData icon,
|
||||||
required String tooltip,
|
required String tooltip,
|
||||||
required VoidCallback onTap,
|
required VoidCallback onTap,
|
||||||
required Color color,
|
Color? color,
|
||||||
}) {
|
}) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
@ -189,13 +188,13 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
|||||||
Tooltip(
|
Tooltip(
|
||||||
message: tooltip,
|
message: tooltip,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(22),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: MouseRegion(
|
child: MouseRegion(
|
||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Icon(icon, color: color, size: 28),
|
child: Icon(icon, color: color, size: 22),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -205,29 +204,27 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openFilterSheet() async {
|
Future<void> _openFilterSheet() async {
|
||||||
final result = await showModalBottomSheet<Map<String, dynamic>>(
|
final result = await showModalBottomSheet<Map<String, dynamic>>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.transparent,
|
||||||
shape: const RoundedRectangleBorder(
|
builder: (context) => DailyProgressReportFilter(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
controller: dailyTaskController,
|
||||||
),
|
permissionController: permissionController,
|
||||||
builder: (context) => DailyProgressReportFilter(
|
),
|
||||||
controller: dailyTaskController,
|
);
|
||||||
permissionController: permissionController,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
final selectedProjectId = result['projectId'] as String?;
|
final selectedProjectId = result['projectId'] as String?;
|
||||||
if (selectedProjectId != null &&
|
if (selectedProjectId != null &&
|
||||||
selectedProjectId != dailyTaskController.selectedProjectId) {
|
selectedProjectId != dailyTaskController.selectedProjectId) {
|
||||||
dailyTaskController.selectedProjectId = selectedProjectId;
|
dailyTaskController.selectedProjectId = selectedProjectId;
|
||||||
await dailyTaskController.fetchTaskData(selectedProjectId);
|
await dailyTaskController.fetchTaskData(selectedProjectId);
|
||||||
dailyTaskController.update(['daily_progress_report_controller']);
|
dailyTaskController.update(['daily_progress_report_controller']);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> _refreshData() async {
|
Future<void> _refreshData() async {
|
||||||
final projectId = dailyTaskController.selectedProjectId;
|
final projectId = dailyTaskController.selectedProjectId;
|
||||||
@ -318,7 +315,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
|||||||
..sort((a, b) => b.compareTo(a));
|
..sort((a, b) => b.compareTo(a));
|
||||||
|
|
||||||
return MyCard.bordered(
|
return MyCard.bordered(
|
||||||
borderRadiusAll: 4,
|
borderRadiusAll: 10,
|
||||||
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||||
shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom),
|
shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom),
|
||||||
paddingAll: 8,
|
paddingAll: 8,
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:marco/helpers/theme/app_theme.dart';
|
import 'package:marco/helpers/theme/app_theme.dart';
|
||||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||||
import 'package:marco/helpers/utils/my_shadow.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_card.dart';
|
import 'package:marco/helpers/widgets/my_card.dart';
|
||||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
@ -160,7 +159,7 @@ class _DailyTaskPlaningScreenState extends State<DailyTaskPlaningScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: MySpacing.x(flexSpacing),
|
padding: MySpacing.x(8),
|
||||||
child: dailyProgressReportTab(),
|
child: dailyProgressReportTab(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -232,10 +231,9 @@ class _DailyTaskPlaningScreenState extends State<DailyTaskPlaningScreen>
|
|||||||
final buildingKey = building.id.toString();
|
final buildingKey = building.id.toString();
|
||||||
|
|
||||||
return MyCard.bordered(
|
return MyCard.bordered(
|
||||||
borderRadiusAll: 12,
|
borderRadiusAll: 10,
|
||||||
paddingAll: 0,
|
paddingAll: 0,
|
||||||
margin: MySpacing.bottom(12),
|
margin: MySpacing.bottom(10),
|
||||||
shadow: MyShadow(elevation: 3),
|
|
||||||
child: Theme(
|
child: Theme(
|
||||||
data: Theme.of(context)
|
data: Theme.of(context)
|
||||||
.copyWith(dividerColor: Colors.transparent),
|
.copyWith(dividerColor: Colors.transparent),
|
||||||
|
|||||||
@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
|
|||||||
set(BINARY_NAME "marco")
|
set(BINARY_NAME "marco")
|
||||||
# The unique GTK application identifier for this application. See:
|
# The unique GTK application identifier for this application. See:
|
||||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||||
set(APPLICATION_ID "com.example.marco")
|
set(APPLICATION_ID "com.marco.aiotstage")
|
||||||
|
|
||||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||||
# versions of CMake.
|
# versions of CMake.
|
||||||
|
|||||||
@ -385,7 +385,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.aiotstage.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco";
|
||||||
@ -399,7 +399,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.aiotstage.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco";
|
||||||
@ -413,7 +413,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.aiotstage.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco";
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
PRODUCT_NAME = marco
|
PRODUCT_NAME = marco
|
||||||
|
|
||||||
// The application's bundle identifier
|
// The application's bundle identifier
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco
|
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage
|
||||||
|
|
||||||
// The copyright displayed in application information
|
// The copyright displayed in application information
|
||||||
PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved.
|
PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved.
|
||||||
|
|||||||
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.0+1
|
version: 1.0.0+5
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.5.3
|
sdk: ^3.5.3
|
||||||
@ -77,6 +77,8 @@ dependencies:
|
|||||||
html_editor_enhanced: ^2.7.0
|
html_editor_enhanced: ^2.7.0
|
||||||
flutter_quill_delta_from_html: ^1.5.2
|
flutter_quill_delta_from_html: ^1.5.2
|
||||||
quill_delta: ^3.0.0-nullsafety.2
|
quill_delta: ^3.0.0-nullsafety.2
|
||||||
|
connectivity_plus: ^6.1.4
|
||||||
|
geocoding: ^4.0.0
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user