Firebase_Final_Code #65
@ -3,6 +3,7 @@ plugins {
|
||||
id "kotlin-android"
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
id("com.google.gms.google-services")
|
||||
}
|
||||
|
||||
// Load keystore properties from key.properties file
|
||||
@ -14,7 +15,7 @@ if (keystorePropertiesFile.exists()) {
|
||||
|
||||
android {
|
||||
// Define the namespace for your Android application
|
||||
namespace = "com.marco.aiotstage"
|
||||
namespace = "com.marco.aiot"
|
||||
// Set the compile SDK version based on Flutter's configuration
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
// Set the NDK version based on Flutter's configuration
|
||||
@ -24,6 +25,8 @@ android {
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
// ✅ Enable core library desugaring for Java 8+ APIs
|
||||
coreLibraryDesugaringEnabled true
|
||||
}
|
||||
|
||||
// Configure Kotlin options for JVM target
|
||||
@ -36,7 +39,7 @@ android {
|
||||
// Specify your unique Application ID. This identifies your app on Google Play.
|
||||
applicationId = "com.marco.aiot"
|
||||
// Set minimum and target SDK versions based on Flutter's configuration
|
||||
minSdk = flutter.minSdkVersion
|
||||
minSdk = 23
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
// Set version code and name based on Flutter's configuration (from pubspec.yaml)
|
||||
versionCode = flutter.versionCode
|
||||
@ -75,3 +78,9 @@ android {
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
// ✅ Add required dependencies for desugaring
|
||||
dependencies {
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||
}
|
||||
|
||||
|
29
android/app/google-services.json
Normal file
29
android/app/google-services.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "626581282477",
|
||||
"project_id": "mtest-a0635",
|
||||
"storage_bucket": "mtest-a0635.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:626581282477:android:8d3cf5009ff92ef67ff024",
|
||||
"android_client_info": {
|
||||
"package_name": "com.marco.aiot"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyCBkDQRpbSdR0bo6pO4Bm0ZIdXkdaE3z-A"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
@ -4,8 +4,8 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<application
|
||||
android:label="Marco"
|
||||
@ -38,6 +38,9 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="high_importance_channel"/>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
@ -1,4 +1,4 @@
|
||||
package com.marco.aiotstage
|
||||
package com.marco.aiot
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
|
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
|
||||
|
@ -18,8 +18,9 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.2.1" apply false
|
||||
id "com.android.application" version "8.6.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
|
||||
id("com.google.gms.google-services") version "4.4.2" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
|
13
assets/service-account.json
Normal file
13
assets/service-account.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "service_account",
|
||||
"project_id": "mtest-a0635",
|
||||
"private_key_id": "39a69f7d2a64234784e0d0ce6c113052296d6dc1",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCimbxktO7PeQ4h\n81Ye2ZBcZjltDhqqD0o9XyLmNdHszzM056bwpJkvgoyyTJAIvR2fcBF3YQFyuC+1\nddLHtchP48FjflZ+zZzLp7oaA/Zh28OZLbCsu+Nm8vO3WJVoIaJYgi+jEz21G128\ncOIbgkKIpLMz1wQhPPOwDTuSdQ+WajWJb04/aNrmTRH1hMreyhHiIFmalcavUgc1\nY5FvgrGs7EaKjYBevoFN3dwmEXjfyHjfBSxnt1yytl9tbtINqdrYLYAMm1l3+KqO\nCGxicQE5kjI1osI2wRjsk105RHnpxPg2GZnI4vTIOkEY5czhRSOs94g2d628H6fq\nVzf9UqwtAgMBAAECggEARluLf3AjHbdd/CbVDwhJRRIeqye9NfTjxOaTrVWAfp2x\npKTQQbSXbE1rIAOtF3rthH3zsNpSzBcS3cwb5rqr8JW2qpySRNAnlp//ER7Bz9pO\nKsvwdO3gGj3qY117WNGk8/NxNXkv7FvpFY8q54hXzdSmjjnt2YwMThOLwXXRxt2B\nFxN3FpBWqw12epqS162nW2nIRJ34Jloil4J5x61Sc79MCFyCxyhMlrBkY+Ni/xb1\nigBXBjczxNiJqqDie0mc16WB1HMEcBP9Yjtb46Hhfs3NDDWNqDkoM8QmEMSg8EHy\nyjcSlf0Wj8I9Kf+0PZo+2FB2DbuhfA8IVR9U/c00KQKBgQDd/OULx6QpmUev1Gl/\nrwwN67ZUMJ72cRuwvLFsMTIzZ+oItO0AR1uMkRZ1crOMc490XNUvSCGP6piZQAn1\nro8qNAh+0Q/UvKHM1khOj/4DxEGZRnNOhe6QLZM9QNygENuEYfdYDD9wcQI9Xs+B\nMIOBsuuqUVHlsbvYkeYNS8M8swKBgQC7g3i1dYRC/bkNMthVS4GTlFRuLscyIjTi\nhruhdaSE+fBZ5RO3XDzz6oDHYcdo/z5ySqI7EIsckNRbwFsMCOjSP3xJapadPYwU\nIhZBU7lgNlPnHJ/BIUwA5JZqRqGTNWrFINUHZFp2RK/x2bYdfoqY8bq08eWs9gmR\nc7U7i+6jnwKBgGaO3isxExD89fewBQWuk70it1vyEp785rQimT3JBM5nJeLb49sL\nHKq2pU+hrH4pLY+vC/cKNidNVS8IPRG6kf4HiB0+7Td15rLCFSnmsI6A72Wm/MK8\ncdk+lRXpj4SMBT8GG8Yb8ns6WrSLxwaCqV8UkHhhlZqvIIAP998Qr6StAoGAQTwr\n8nU/3k6G4qCdwo7SNZWVCgAcLMTZwTU+cZ2L7vdFNwELKu9cBT/ALZ1G0rB5+Skd\n546J1xZLyt/QzQ8McJjFlIUQgQO4iAiT1YZbJ62+4tiCe54p4uWjrrWD4MLkslAJ\nzNiM4DhlPa6QPRKZBTyTx/+f99xg18l5c43rJ+ECgYBkXMfjdn8SOaG6ggJrf1xx\nas49vwAscx4AJaOdVu3D8lCwoNCuAJhBHcFqsJ0wEHWpsqKAdXxqX/Nt2x8t7zL0\nPoRCvfsq5P7GdRrNhrHxLwjDqh+OS+Ow6t0esPQ5RPBgtjvthAlb7bV2nIfkpmdl\nFbjML8vkXk9iPJsbAfO2jw==\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "firebase-adminsdk-fbsvc@mtest-a0635.iam.gserviceaccount.com",
|
||||
"client_id": "111097905744982732087",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40mtest-a0635.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
@ -368,7 +368,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@ -384,7 +384,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@ -401,7 +401,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@ -416,7 +416,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@ -547,7 +547,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@ -569,7 +569,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
@ -6,7 +6,7 @@ import 'package:marco/helpers/widgets/my_form_validator.dart';
|
||||
import 'package:marco/helpers/widgets/my_validators.dart';
|
||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart'; // <-- logging
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
|
||||
class LoginController extends MyController {
|
||||
final MyFormValidator basicValidator = MyFormValidator();
|
||||
@ -55,12 +55,14 @@ class LoginController extends MyController {
|
||||
|
||||
try {
|
||||
final loginData = basicValidator.getData();
|
||||
logSafe("Attempting login for user: ${loginData['username']}", );
|
||||
logSafe("Attempting login for user: ${loginData['username']}");
|
||||
|
||||
final errors = await AuthService.loginUser(loginData);
|
||||
|
||||
if (errors != null) {
|
||||
logSafe("Login failed for user: ${loginData['username']} with errors: $errors", level: LogLevel.warning, );
|
||||
logSafe(
|
||||
"Login failed for user: ${loginData['username']} with errors: $errors",
|
||||
level: LogLevel.warning);
|
||||
|
||||
showAppSnackbar(
|
||||
title: "Login Failed",
|
||||
@ -73,11 +75,26 @@ class LoginController extends MyController {
|
||||
basicValidator.clearErrors();
|
||||
} else {
|
||||
await _handleRememberMe();
|
||||
logSafe("Login successful for user: ${loginData['username']}", );
|
||||
|
||||
// ✅ Commented out FCM token registration after login
|
||||
/*
|
||||
final fcmToken = await LocalStorage.getFcmToken();
|
||||
if (fcmToken?.isNotEmpty ?? false) {
|
||||
final success = await AuthService.registerDeviceToken(fcmToken!);
|
||||
logSafe(
|
||||
success
|
||||
? "✅ FCM token registered after login."
|
||||
: "⚠️ Failed to register FCM token after login.",
|
||||
level: LogLevel.warning);
|
||||
}
|
||||
*/
|
||||
|
||||
logSafe("Login successful for user: ${loginData['username']}");
|
||||
Get.toNamed('/home');
|
||||
}
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Exception during login", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
logSafe("Exception during login",
|
||||
level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
showAppSnackbar(
|
||||
title: "Login Error",
|
||||
message: "An unexpected error occurred",
|
||||
@ -90,8 +107,10 @@ class LoginController extends MyController {
|
||||
|
||||
Future<void> _handleRememberMe() async {
|
||||
if (isChecked.value) {
|
||||
await LocalStorage.setToken('username', basicValidator.getController('username')!.text);
|
||||
await LocalStorage.setToken('password', basicValidator.getController('password')!.text);
|
||||
await LocalStorage.setToken(
|
||||
'username', basicValidator.getController('username')!.text);
|
||||
await LocalStorage.setToken(
|
||||
'password', basicValidator.getController('password')!.text);
|
||||
await LocalStorage.setBool('remember_me', true);
|
||||
} else {
|
||||
await LocalStorage.removeToken('username');
|
||||
|
@ -6,6 +6,7 @@ import 'package:marco/helpers/widgets/my_form_validator.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/view/dashboard/dashboard_screen.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
// import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart'; // 🔴 Commented out
|
||||
|
||||
class MPINController extends GetxController {
|
||||
final MyFormValidator basicValidator = MyFormValidator();
|
||||
@ -42,7 +43,8 @@ class MPINController extends GetxController {
|
||||
|
||||
/// Handle digit entry and focus movement
|
||||
void onDigitChanged(String value, int index, {bool isRetype = false}) {
|
||||
logSafe("onDigitChanged -> index: $index, value: $value, isRetype: $isRetype");
|
||||
logSafe(
|
||||
"onDigitChanged -> index: $index, value: $value, isRetype: $isRetype");
|
||||
final nodes = isRetype ? retypeFocusNodes : focusNodes;
|
||||
if (value.isNotEmpty && index < 3) {
|
||||
nodes[index + 1].requestFocus();
|
||||
@ -212,7 +214,8 @@ class MPINController extends GetxController {
|
||||
if (response == null) {
|
||||
return true;
|
||||
} else {
|
||||
logSafe("MPIN generation returned error: $response", level: LogLevel.warning);
|
||||
logSafe("MPIN generation returned error: $response",
|
||||
level: LogLevel.warning);
|
||||
showAppSnackbar(
|
||||
title: "MPIN Operation Failed",
|
||||
message: "Please check your inputs.",
|
||||
@ -253,9 +256,14 @@ class MPINController extends GetxController {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
// ✅ Fetch FCM Token here (DISABLED)
|
||||
// final fcmToken = await FirebaseNotificationService().getFcmToken();
|
||||
|
||||
final response = await AuthService.verifyMpin(
|
||||
mpin: enteredMPIN,
|
||||
mpinToken: mpinToken,
|
||||
// fcmToken: fcmToken ?? '', // 🔴 Commented out
|
||||
fcmToken: '', // ✅ Passing empty string instead
|
||||
);
|
||||
|
||||
isLoading.value = false;
|
||||
@ -272,7 +280,8 @@ class MPINController extends GetxController {
|
||||
_navigateToDashboard();
|
||||
} else {
|
||||
final errorMessage = response["error"] ?? "Invalid MPIN";
|
||||
logSafe("MPIN verification failed: $errorMessage", level: LogLevel.warning);
|
||||
logSafe("MPIN verification failed: $errorMessage",
|
||||
level: LogLevel.warning);
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: errorMessage,
|
||||
|
@ -60,6 +60,18 @@ class AttendanceController extends GetxController {
|
||||
}
|
||||
|
||||
// ------------------ Project & Employee ------------------
|
||||
/// Called when a notification says attendance has been updated
|
||||
Future<void> refreshDataFromNotification({String? projectId}) async {
|
||||
projectId ??= Get.find<ProjectController>().selectedProject?.id;
|
||||
if (projectId == null) {
|
||||
logSafe("No project selected for attendance refresh from notification",
|
||||
level: LogLevel.warning);
|
||||
return;
|
||||
}
|
||||
await fetchProjectData(projectId);
|
||||
logSafe(
|
||||
"Attendance data refreshed from notification for project $projectId");
|
||||
}
|
||||
|
||||
Future<void> fetchProjects() async {
|
||||
isLoadingProjects.value = true;
|
||||
@ -94,7 +106,6 @@ class AttendanceController extends GetxController {
|
||||
logSafe("Failed to fetch employees for project $projectId",
|
||||
level: LogLevel.error);
|
||||
}
|
||||
|
||||
isLoadingEmployees.value = false;
|
||||
update();
|
||||
}
|
||||
|
@ -49,7 +49,8 @@ class DailyTaskController extends GetxController {
|
||||
|
||||
Future<void> fetchTaskData(String? projectId) async {
|
||||
if (projectId == null) {
|
||||
logSafe("fetchTaskData: Skipped, projectId is null", level: LogLevel.warning);
|
||||
logSafe("fetchTaskData: Skipped, projectId is null",
|
||||
level: LogLevel.warning);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -99,7 +100,8 @@ class DailyTaskController extends GetxController {
|
||||
firstDate: DateTime(2022),
|
||||
lastDate: DateTime.now(),
|
||||
initialDateRange: DateTimeRange(
|
||||
start: startDateTask ?? DateTime.now().subtract(const Duration(days: 7)),
|
||||
start:
|
||||
startDateTask ?? DateTime.now().subtract(const Duration(days: 7)),
|
||||
end: endDateTask ?? DateTime.now(),
|
||||
),
|
||||
);
|
||||
@ -119,4 +121,15 @@ class DailyTaskController extends GetxController {
|
||||
|
||||
await controller.fetchTaskData(controller.selectedProjectId);
|
||||
}
|
||||
|
||||
void refreshTasksFromNotification({
|
||||
required String projectId,
|
||||
required String taskAllocationId,
|
||||
}) async {
|
||||
// re-fetch tasks
|
||||
await fetchTaskData(projectId);
|
||||
|
||||
update(); // rebuilds UI
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -181,15 +181,27 @@ class AddExpenseController extends GetxController {
|
||||
|
||||
// --- Pickers ---
|
||||
Future<void> pickTransactionDate(BuildContext context) async {
|
||||
final picked = await showDatePicker(
|
||||
final pickedDate = 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);
|
||||
|
||||
if (pickedDate != null) {
|
||||
final now = DateTime.now();
|
||||
final finalDateTime = DateTime(
|
||||
pickedDate.year,
|
||||
pickedDate.month,
|
||||
pickedDate.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
);
|
||||
|
||||
selectedTransactionDate.value = finalDateTime;
|
||||
transactionDateController.text =
|
||||
DateFormat('dd MMM yyyy').format(finalDateTime);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,79 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:url_strategy/url_strategy.dart';
|
||||
// import 'package:firebase_core/firebase_core.dart'; // ❌ Commented out Firebase
|
||||
|
||||
import 'package:marco/controller/permission_controller.dart';
|
||||
import 'package:marco/controller/project_controller.dart';
|
||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||
import 'package:marco/helpers/theme/theme_customizer.dart';
|
||||
import 'package:marco/helpers/theme/app_theme.dart';
|
||||
import 'package:url_strategy/url_strategy.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/helpers/services/auth_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
// import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart'; // ❌ Commented out FCM
|
||||
import 'package:marco/helpers/services/device_info_service.dart';
|
||||
import 'package:marco/helpers/theme/theme_customizer.dart';
|
||||
import 'package:marco/helpers/theme/app_theme.dart';
|
||||
|
||||
Future<void> initializeApp() async {
|
||||
try {
|
||||
logSafe("💡 Starting app initialization...");
|
||||
|
||||
// UI Setup
|
||||
setPathUrlStrategy();
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
systemNavigationBarIconBrightness: Brightness.dark,
|
||||
),
|
||||
);
|
||||
logSafe("💡 UI setup completed.");
|
||||
await Future.wait([
|
||||
_setupUI(),
|
||||
// _setupFirebase(), // ❌ Commented out Firebase init
|
||||
_setupLocalStorage(),
|
||||
]);
|
||||
|
||||
// Local storage
|
||||
await LocalStorage.init();
|
||||
logSafe("💡 Local storage initialized.");
|
||||
await _setupDeviceInfo();
|
||||
await _handleAuthTokens();
|
||||
await _setupTheme();
|
||||
await _setupControllers();
|
||||
// await _setupFirebaseMessaging(); // ❌ Commented out FCM init
|
||||
|
||||
// Token handling
|
||||
final refreshToken = await LocalStorage.getRefreshToken();
|
||||
final hasRefreshToken = refreshToken?.isNotEmpty ?? false;
|
||||
|
||||
if (hasRefreshToken) {
|
||||
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
|
||||
final success = await AuthService.refreshToken();
|
||||
if (!success) {
|
||||
logSafe("⚠️ Refresh token invalid or expired. Skipping controller injection.");
|
||||
// Optionally clear tokens or handle logout here
|
||||
}
|
||||
} else {
|
||||
logSafe("❌ No refresh token found. Skipping refresh.");
|
||||
}
|
||||
|
||||
// Theme setup
|
||||
await ThemeCustomizer.init();
|
||||
logSafe("💡 Theme customizer initialized.");
|
||||
|
||||
// Controller setup
|
||||
final token = LocalStorage.getString('jwt_token');
|
||||
final hasJwt = token?.isNotEmpty ?? false;
|
||||
|
||||
if (hasJwt) {
|
||||
if (!Get.isRegistered<PermissionController>()) {
|
||||
Get.put(PermissionController());
|
||||
logSafe("💡 PermissionController injected.");
|
||||
}
|
||||
|
||||
if (!Get.isRegistered<ProjectController>()) {
|
||||
Get.put(ProjectController(), permanent: true);
|
||||
logSafe("💡 ProjectController injected as permanent.");
|
||||
}
|
||||
|
||||
await Get.find<PermissionController>().loadData(token!);
|
||||
await Get.find<ProjectController>().fetchProjects();
|
||||
} else {
|
||||
logSafe("⚠️ No valid JWT token found. Skipping controller initialization.");
|
||||
}
|
||||
|
||||
// Final style setup
|
||||
AppStyle.init();
|
||||
logSafe("💡 AppStyle initialized.");
|
||||
_finalizeAppStyle();
|
||||
|
||||
logSafe("✅ App initialization completed successfully.");
|
||||
} catch (e, stacktrace) {
|
||||
@ -86,3 +43,89 @@ Future<void> initializeApp() async {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setupUI() async {
|
||||
setPathUrlStrategy();
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
systemNavigationBarIconBrightness: Brightness.dark,
|
||||
));
|
||||
logSafe("💡 UI setup completed.");
|
||||
}
|
||||
|
||||
// ❌ Commented out Firebase setup
|
||||
/*
|
||||
Future<void> _setupFirebase() async {
|
||||
await Firebase.initializeApp();
|
||||
logSafe("💡 Firebase initialized.");
|
||||
}
|
||||
*/
|
||||
|
||||
Future<void> _setupLocalStorage() async {
|
||||
await LocalStorage.init();
|
||||
logSafe("💡 Local storage initialized.");
|
||||
}
|
||||
|
||||
Future<void> _setupDeviceInfo() async {
|
||||
final deviceInfoService = DeviceInfoService();
|
||||
await deviceInfoService.init();
|
||||
logSafe("📱 Device Info: ${deviceInfoService.deviceData}");
|
||||
}
|
||||
|
||||
Future<void> _handleAuthTokens() async {
|
||||
final refreshToken = await LocalStorage.getRefreshToken();
|
||||
if (refreshToken?.isNotEmpty ?? false) {
|
||||
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
|
||||
final success = await AuthService.refreshToken();
|
||||
if (!success) {
|
||||
logSafe(
|
||||
"⚠️ Refresh token invalid or expired. Skipping controller injection.");
|
||||
}
|
||||
} else {
|
||||
logSafe("❌ No refresh token found. Skipping refresh.");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setupTheme() async {
|
||||
await ThemeCustomizer.init();
|
||||
logSafe("💡 Theme customizer initialized.");
|
||||
}
|
||||
|
||||
Future<void> _setupControllers() async {
|
||||
final token = LocalStorage.getString('jwt_token');
|
||||
if (token?.isEmpty ?? true) {
|
||||
logSafe("⚠️ No valid JWT token found. Skipping controller initialization.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Get.isRegistered<PermissionController>()) {
|
||||
Get.put(PermissionController());
|
||||
logSafe("💡 PermissionController injected.");
|
||||
}
|
||||
|
||||
if (!Get.isRegistered<ProjectController>()) {
|
||||
Get.put(ProjectController(), permanent: true);
|
||||
logSafe("💡 ProjectController injected as permanent.");
|
||||
}
|
||||
|
||||
await Future.wait([
|
||||
Get.find<PermissionController>().loadData(token!),
|
||||
Get.find<ProjectController>().fetchProjects(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ❌ Commented out Firebase Messaging setup
|
||||
/*
|
||||
Future<void> _setupFirebaseMessaging() async {
|
||||
await FirebaseNotificationService().initialize();
|
||||
logSafe("💡 Firebase Messaging initialized.");
|
||||
}
|
||||
*/
|
||||
|
||||
void _finalizeAppStyle() {
|
||||
AppStyle.init();
|
||||
logSafe("💡 AppStyle initialized.");
|
||||
}
|
||||
|
@ -15,278 +15,293 @@ class AuthService {
|
||||
};
|
||||
|
||||
static bool isLoggedIn = false;
|
||||
|
||||
/// Login with email and password
|
||||
static Future<Map<String, String>?> loginUser(Map<String, dynamic> data) async {
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Logout API */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
static Future<bool> logoutApi(String refreshToken, String fcmToken) async {
|
||||
try {
|
||||
logSafe("Attempting login...");
|
||||
final response = await http.post(
|
||||
Uri.parse("$_baseUrl/auth/login-mobile"),
|
||||
headers: _headers,
|
||||
body: jsonEncode(data),
|
||||
);
|
||||
final body = {
|
||||
"refreshToken": refreshToken,
|
||||
"fcmToken": fcmToken,
|
||||
};
|
||||
|
||||
final responseData = jsonDecode(response.body);
|
||||
if (response.statusCode == 200 && responseData['data'] != null) {
|
||||
await _handleLoginSuccess(responseData['data']);
|
||||
return null;
|
||||
} else if (response.statusCode == 401) {
|
||||
logSafe("Invalid login credentials.", level: LogLevel.warning);
|
||||
return {"password": "Invalid email or password"};
|
||||
} else {
|
||||
logSafe("Login error: ${responseData['message']}", level: LogLevel.warning);
|
||||
return {"error": responseData['message'] ?? "Unexpected error occurred"};
|
||||
final response = await _post("/auth/logout", body);
|
||||
|
||||
if (response != null && response['statusCode'] == 200) {
|
||||
logSafe("✅ Logout API successful");
|
||||
return true;
|
||||
}
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Login exception", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
return {"error": "Network error. Please check your connection."};
|
||||
|
||||
logSafe("⚠️ Logout API failed: ${response?['message']}",
|
||||
level: LogLevel.warning);
|
||||
return false;
|
||||
} catch (e, st) {
|
||||
_handleError("Logout API error", e, st);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh JWT token
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Public Methods */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
static Future<bool> registerDeviceToken(String fcmToken) async {
|
||||
final token = await LocalStorage.getJwtToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
logSafe("❌ Cannot register device token: missing JWT token",
|
||||
level: LogLevel.warning);
|
||||
return false;
|
||||
}
|
||||
|
||||
final body = {"fcmToken": fcmToken};
|
||||
final data = await _post("/auth/set/device-token", body, authToken: token);
|
||||
if (data != null && data['success'] == true) {
|
||||
logSafe("✅ Device token registered successfully.");
|
||||
return true;
|
||||
}
|
||||
logSafe("⚠️ Failed to register device token: ${data?['message']}",
|
||||
level: LogLevel.warning);
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<Map<String, String>?> loginUser(
|
||||
Map<String, dynamic> data) async {
|
||||
logSafe("Attempting login...");
|
||||
logSafe("Login payload (raw): $data");
|
||||
logSafe("Login payload (JSON): ${jsonEncode(data)}");
|
||||
|
||||
final responseData = await _post("/auth/login-mobile", data);
|
||||
if (responseData == null)
|
||||
return {"error": "Network error. Please check your connection."};
|
||||
|
||||
if (responseData['data'] != null) {
|
||||
await _handleLoginSuccess(responseData['data']);
|
||||
return null;
|
||||
}
|
||||
if (responseData['statusCode'] == 401) {
|
||||
return {"password": "Invalid email or password"};
|
||||
}
|
||||
return {"error": responseData['message'] ?? "Unexpected error occurred"};
|
||||
}
|
||||
|
||||
static Future<bool> refreshToken() async {
|
||||
final accessToken = await LocalStorage.getJwtToken();
|
||||
final refreshToken = await LocalStorage.getRefreshToken();
|
||||
|
||||
if (accessToken == null || refreshToken == null || accessToken.isEmpty || refreshToken.isEmpty) {
|
||||
if ([accessToken, refreshToken].any((t) => t == null || t.isEmpty)) {
|
||||
logSafe("Missing access or refresh token.", level: LogLevel.warning);
|
||||
return false;
|
||||
}
|
||||
|
||||
final requestBody = {
|
||||
"token": accessToken,
|
||||
"refreshToken": refreshToken,
|
||||
};
|
||||
final body = {"token": accessToken, "refreshToken": refreshToken};
|
||||
final data = await _post("/auth/refresh-token", body);
|
||||
if (data != null && data['success'] == true) {
|
||||
await LocalStorage.setJwtToken(data['data']['token']);
|
||||
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
|
||||
await LocalStorage.setLoggedInUser(true);
|
||||
logSafe("Token refreshed successfully.");
|
||||
|
||||
try {
|
||||
logSafe("Refreshing token...");
|
||||
final response = await http.post(
|
||||
Uri.parse("$_baseUrl/auth/refresh-token"),
|
||||
headers: _headers,
|
||||
body: jsonEncode(requestBody),
|
||||
);
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
if (response.statusCode == 200 && data['success'] == true) {
|
||||
await LocalStorage.setJwtToken(data['data']['token']);
|
||||
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
|
||||
await LocalStorage.setLoggedInUser(true);
|
||||
logSafe("Token refreshed successfully.");
|
||||
return true;
|
||||
} else {
|
||||
logSafe("Refresh token failed: ${data['message']}", level: LogLevel.warning);
|
||||
return false;
|
||||
// 🔹 Retry FCM token registration after token refresh
|
||||
final newFcmToken = await LocalStorage.getFcmToken();
|
||||
if (newFcmToken?.isNotEmpty ?? false) {
|
||||
final success = await registerDeviceToken(newFcmToken!);
|
||||
logSafe(
|
||||
success
|
||||
? "✅ FCM token re-registered after JWT refresh."
|
||||
: "⚠️ Failed to register FCM token after JWT refresh.",
|
||||
level: success ? LogLevel.info : LogLevel.warning);
|
||||
}
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Token refresh exception", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
logSafe("Refresh token failed: ${data?['message']}",
|
||||
level: LogLevel.warning);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Forgot password
|
||||
static Future<Map<String, String>?> forgotPassword(String email) async {
|
||||
try {
|
||||
logSafe("Forgot password requested.");
|
||||
final response = await http.post(
|
||||
Uri.parse("$_baseUrl/auth/forgot-password"),
|
||||
headers: _headers,
|
||||
body: jsonEncode({"email": email}),
|
||||
);
|
||||
static Future<Map<String, String>?> forgotPassword(String email) =>
|
||||
_wrapErrorHandling(() => _post("/auth/forgot-password", {"email": email}),
|
||||
successCondition: (data) => data['success'] == true,
|
||||
defaultError: "Failed to send reset link.");
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
if (response.statusCode == 200 && data['success'] == true) return null;
|
||||
return {"error": data['message'] ?? "Failed to send reset link."};
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Forgot password error", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
return {"error": "Network error. Please check your connection."};
|
||||
}
|
||||
}
|
||||
static Future<Map<String, String>?> requestDemo(
|
||||
Map<String, dynamic> demoData) =>
|
||||
_wrapErrorHandling(() => _post("/market/inquiry", demoData),
|
||||
successCondition: (data) => data['success'] == true,
|
||||
defaultError: "Failed to submit demo request.");
|
||||
|
||||
/// Request demo
|
||||
static Future<Map<String, String>?> requestDemo(Map<String, dynamic> demoData) async {
|
||||
try {
|
||||
logSafe("Submitting demo request...");
|
||||
final response = await http.post(
|
||||
Uri.parse("$_baseUrl/market/inquiry"),
|
||||
headers: _headers,
|
||||
body: jsonEncode(demoData),
|
||||
);
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
if (response.statusCode == 200 && data['success'] == true) return null;
|
||||
return {"error": data['message'] ?? "Failed to submit demo request."};
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Request demo error", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
return {"error": "Network error. Please check your connection."};
|
||||
}
|
||||
}
|
||||
|
||||
/// Get list of industries
|
||||
static Future<List<Map<String, dynamic>>?> getIndustries() async {
|
||||
try {
|
||||
logSafe("Fetching industries list...");
|
||||
final response = await http.get(
|
||||
Uri.parse("$_baseUrl/market/industries"),
|
||||
headers: _headers,
|
||||
);
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
if (response.statusCode == 200 && data['success'] == true) {
|
||||
return List<Map<String, dynamic>>.from(data['data']);
|
||||
}
|
||||
return null;
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Get industries error", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
return null;
|
||||
final data = await _get("/market/industries");
|
||||
if (data != null && data['success'] == true) {
|
||||
return List<Map<String, dynamic>>.from(data['data']);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Generate MPIN
|
||||
static Future<Map<String, String>?> generateMpin({
|
||||
required String employeeId,
|
||||
required String mpin,
|
||||
}) async {
|
||||
final token = await LocalStorage.getJwtToken();
|
||||
logSafe("Generating MPIN for employeeId: $employeeId");
|
||||
logSafe("MPIN: $mpin");
|
||||
try {
|
||||
logSafe("Generating MPIN...");
|
||||
final response = await http.post(
|
||||
Uri.parse("$_baseUrl/auth/generate-mpin"),
|
||||
headers: {
|
||||
..._headers,
|
||||
if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token',
|
||||
}) =>
|
||||
_wrapErrorHandling(
|
||||
() async {
|
||||
final token = await LocalStorage.getJwtToken();
|
||||
return _post(
|
||||
"/auth/generate-mpin",
|
||||
{"employeeId": employeeId, "mpin": mpin},
|
||||
authToken: token,
|
||||
);
|
||||
},
|
||||
body: jsonEncode({"employeeId": employeeId, "mpin": mpin}),
|
||||
successCondition: (data) => data['success'] == true,
|
||||
defaultError: "Failed to generate MPIN.",
|
||||
);
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
if (response.statusCode == 200 && data['success'] == true) return null;
|
||||
return {"error": data['message'] ?? "Failed to generate MPIN."};
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Generate MPIN error", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
return {"error": "Network error. Please check your connection."};
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify MPIN
|
||||
static Future<Map<String, String>?> verifyMpin({
|
||||
required String mpin,
|
||||
required String mpinToken,
|
||||
}) async {
|
||||
final employeeInfo = LocalStorage.getEmployeeInfo();
|
||||
if (employeeInfo == null) return {"error": "Employee info not found."};
|
||||
|
||||
final token = await LocalStorage.getJwtToken();
|
||||
|
||||
try {
|
||||
logSafe("Verifying MPIN...");
|
||||
final response = await http.post(
|
||||
Uri.parse("$_baseUrl/auth/login-mpin"),
|
||||
headers: {
|
||||
..._headers,
|
||||
if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token',
|
||||
required String fcmToken,
|
||||
}) =>
|
||||
_wrapErrorHandling(
|
||||
() async {
|
||||
final employeeInfo = LocalStorage.getEmployeeInfo();
|
||||
if (employeeInfo == null) return null;
|
||||
final token = await LocalStorage.getJwtToken();
|
||||
return _post(
|
||||
"/auth/login-mpin",
|
||||
{
|
||||
"employeeId": employeeInfo.id,
|
||||
"mpin": mpin,
|
||||
"mpinToken": mpinToken,
|
||||
"fcmToken": fcmToken,
|
||||
},
|
||||
authToken: token,
|
||||
);
|
||||
},
|
||||
body: jsonEncode({
|
||||
"employeeId": employeeInfo.id,
|
||||
"mpin": mpin,
|
||||
"mpinToken": mpinToken,
|
||||
}),
|
||||
successCondition: (data) => data['success'] == true,
|
||||
defaultError: "MPIN verification failed.",
|
||||
);
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
if (response.statusCode == 200 && data['success'] == true) return null;
|
||||
return {"error": data['message'] ?? "MPIN verification failed."};
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Verify MPIN error", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
return {"error": "Network error. Please check your connection."};
|
||||
}
|
||||
}
|
||||
static Future<Map<String, String>?> generateOtp(String email) =>
|
||||
_wrapErrorHandling(() => _post("/auth/send-otp", {"email": email}),
|
||||
successCondition: (data) => data['success'] == true,
|
||||
defaultError: "Failed to generate OTP.");
|
||||
|
||||
/// Generate OTP
|
||||
static Future<Map<String, String>?> generateOtp(String email) async {
|
||||
try {
|
||||
logSafe("Generating OTP for email...");
|
||||
final response = await http.post(
|
||||
Uri.parse("$_baseUrl/auth/send-otp"),
|
||||
headers: _headers,
|
||||
body: jsonEncode({"email": email}),
|
||||
);
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
if (response.statusCode == 200 && data['success'] == true) return null;
|
||||
return {"error": data['message'] ?? "Failed to generate OTP."};
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Generate OTP error", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
return {"error": "Network error. Please check your connection."};
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify OTP and login
|
||||
static Future<Map<String, String>?> verifyOtp({
|
||||
required String email,
|
||||
required String otp,
|
||||
}) async {
|
||||
try {
|
||||
logSafe("Verifying OTP...");
|
||||
final response = await http.post(
|
||||
Uri.parse("$_baseUrl/auth/login-otp"),
|
||||
headers: _headers,
|
||||
body: jsonEncode({"email": email, "otp": otp}),
|
||||
);
|
||||
final data = await _post("/auth/login-otp", {"email": email, "otp": otp});
|
||||
if (data != null && data['data'] != null) {
|
||||
await _handleLoginSuccess(data['data']);
|
||||
return null;
|
||||
}
|
||||
return {"error": data?['message'] ?? "OTP verification failed."};
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
if (response.statusCode == 200 && data['data'] != null) {
|
||||
await _handleLoginSuccess(data['data']);
|
||||
return null;
|
||||
}
|
||||
return {"error": data['message'] ?? "OTP verification failed."};
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Verify OTP error", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
return {"error": "Network error. Please check your connection."};
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Private Utilities */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
static Future<Map<String, dynamic>?> _post(
|
||||
String path,
|
||||
Map<String, dynamic> body, {
|
||||
String? authToken,
|
||||
}) async {
|
||||
try {
|
||||
final headers = {
|
||||
..._headers,
|
||||
if (authToken?.isNotEmpty ?? false)
|
||||
'Authorization': 'Bearer $authToken',
|
||||
};
|
||||
final response = await http.post(Uri.parse("$_baseUrl$path"),
|
||||
headers: headers, body: jsonEncode(body));
|
||||
return {
|
||||
...jsonDecode(response.body),
|
||||
"statusCode": response.statusCode,
|
||||
};
|
||||
} catch (e, st) {
|
||||
_handleError("$path POST error", e, st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle login success flow
|
||||
static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async {
|
||||
logSafe("Processing login success...");
|
||||
|
||||
final jwtToken = data['token'];
|
||||
final refreshToken = data['refreshToken'];
|
||||
final mpinToken = data['mpinToken'];
|
||||
|
||||
// Save tokens
|
||||
await LocalStorage.setJwtToken(jwtToken);
|
||||
await LocalStorage.setLoggedInUser(true);
|
||||
|
||||
if (refreshToken != null) {
|
||||
await LocalStorage.setRefreshToken(refreshToken);
|
||||
static Future<Map<String, dynamic>?> _get(
|
||||
String path, {
|
||||
String? authToken,
|
||||
}) async {
|
||||
try {
|
||||
final headers = {
|
||||
..._headers,
|
||||
if (authToken?.isNotEmpty ?? false)
|
||||
'Authorization': 'Bearer $authToken',
|
||||
};
|
||||
final response =
|
||||
await http.get(Uri.parse("$_baseUrl$path"), headers: headers);
|
||||
return {
|
||||
...jsonDecode(response.body),
|
||||
"statusCode": response.statusCode,
|
||||
};
|
||||
} catch (e, st) {
|
||||
_handleError("$path GET error", e, st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (mpinToken != null && mpinToken.isNotEmpty) {
|
||||
await LocalStorage.setMpinToken(mpinToken);
|
||||
await LocalStorage.setIsMpin(true);
|
||||
} else {
|
||||
await LocalStorage.setIsMpin(false);
|
||||
await LocalStorage.removeMpinToken();
|
||||
static Future<Map<String, String>?> _wrapErrorHandling(
|
||||
Future<Map<String, dynamic>?> Function() request, {
|
||||
required bool Function(Map<String, dynamic> data) successCondition,
|
||||
required String defaultError,
|
||||
}) async {
|
||||
final data = await request();
|
||||
if (data != null && successCondition(data)) return null;
|
||||
return {"error": data?['message'] ?? defaultError};
|
||||
}
|
||||
|
||||
// Inject controllers if not already registered
|
||||
if (!Get.isRegistered<PermissionController>()) {
|
||||
Get.put(PermissionController());
|
||||
logSafe("✅ PermissionController injected after login.");
|
||||
static void _handleError(String message, Object error, StackTrace st) {
|
||||
logSafe(message, level: LogLevel.error, error: error, stackTrace: st);
|
||||
}
|
||||
|
||||
if (!Get.isRegistered<ProjectController>()) {
|
||||
Get.put(ProjectController(), permanent: true);
|
||||
logSafe("✅ ProjectController injected after login.");
|
||||
static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async {
|
||||
logSafe("Processing login success...");
|
||||
|
||||
await LocalStorage.setJwtToken(data['token']);
|
||||
await LocalStorage.setLoggedInUser(true);
|
||||
|
||||
if (data['refreshToken'] != null) {
|
||||
await LocalStorage.setRefreshToken(data['refreshToken']);
|
||||
}
|
||||
|
||||
if (data['mpinToken']?.isNotEmpty ?? false) {
|
||||
await LocalStorage.setMpinToken(data['mpinToken']);
|
||||
await LocalStorage.setIsMpin(true);
|
||||
} else {
|
||||
await LocalStorage.setIsMpin(false);
|
||||
await LocalStorage.removeMpinToken();
|
||||
}
|
||||
|
||||
if (!Get.isRegistered<PermissionController>()) {
|
||||
Get.put(PermissionController());
|
||||
logSafe("✅ PermissionController injected after login.");
|
||||
}
|
||||
if (!Get.isRegistered<ProjectController>()) {
|
||||
Get.put(ProjectController(), permanent: true);
|
||||
logSafe("✅ ProjectController injected after login.");
|
||||
}
|
||||
|
||||
await Get.find<PermissionController>().loadData(data['token']);
|
||||
await Get.find<ProjectController>().fetchProjects();
|
||||
|
||||
// 🔹 Always try to register FCM token after login
|
||||
final fcmToken = await LocalStorage.getFcmToken();
|
||||
if (fcmToken?.isNotEmpty ?? false) {
|
||||
final success = await registerDeviceToken(fcmToken!);
|
||||
logSafe(
|
||||
success
|
||||
? "✅ FCM token registered after login."
|
||||
: "⚠️ Failed to register FCM token after login.",
|
||||
level: success ? LogLevel.info : LogLevel.warning);
|
||||
}
|
||||
|
||||
isLoggedIn = true;
|
||||
logSafe("✅ Login flow completed and controllers initialized.");
|
||||
}
|
||||
|
||||
// Load data into controllers
|
||||
await Get.find<PermissionController>().loadData(jwtToken);
|
||||
await Get.find<ProjectController>().fetchProjects();
|
||||
|
||||
isLoggedIn = true;
|
||||
logSafe("✅ Login flow completed and controllers initialized.");
|
||||
}
|
||||
}
|
51
lib/helpers/services/device_info_service.dart
Normal file
51
lib/helpers/services/device_info_service.dart
Normal file
@ -0,0 +1,51 @@
|
||||
import 'dart:io';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
|
||||
class DeviceInfoService {
|
||||
static final DeviceInfoService _instance = DeviceInfoService._internal();
|
||||
factory DeviceInfoService() => _instance;
|
||||
DeviceInfoService._internal();
|
||||
|
||||
final DeviceInfoPlugin _deviceInfoPlugin = DeviceInfoPlugin();
|
||||
Map<String, dynamic> _deviceData = {};
|
||||
|
||||
/// Initialize device info (call this in main before runApp)
|
||||
Future<void> init() async {
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
final androidInfo = await _deviceInfoPlugin.androidInfo;
|
||||
_deviceData = {
|
||||
'platform': 'Android',
|
||||
'manufacturer': androidInfo.manufacturer,
|
||||
'model': androidInfo.model,
|
||||
'version': androidInfo.version.release,
|
||||
'sdkInt': androidInfo.version.sdkInt,
|
||||
'brand': androidInfo.brand,
|
||||
'device': androidInfo.device,
|
||||
'androidId': androidInfo.id,
|
||||
};
|
||||
} else if (Platform.isIOS) {
|
||||
final iosInfo = await _deviceInfoPlugin.iosInfo;
|
||||
_deviceData = {
|
||||
'platform': 'iOS',
|
||||
'name': iosInfo.name,
|
||||
'systemName': iosInfo.systemName,
|
||||
'systemVersion': iosInfo.systemVersion,
|
||||
'model': iosInfo.model,
|
||||
'localizedModel': iosInfo.localizedModel,
|
||||
'identifierForVendor': iosInfo.identifierForVendor,
|
||||
};
|
||||
} else {
|
||||
_deviceData = {'platform': 'Unknown'};
|
||||
}
|
||||
} catch (e) {
|
||||
_deviceData = {'error': 'Failed to get device info: $e'};
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the whole device info map
|
||||
Map<String, dynamic> get deviceData => _deviceData;
|
||||
|
||||
/// Get a specific property from device info
|
||||
String? getProperty(String key) => _deviceData[key]?.toString();
|
||||
}
|
220
lib/helpers/services/firebase/firebase_messaging_service.dart
Normal file
220
lib/helpers/services/firebase/firebase_messaging_service.dart
Normal file
@ -0,0 +1,220 @@
|
||||
import 'dart:convert';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:googleapis_auth/auth_io.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
|
||||
import 'package:marco/helpers/services/local_notification_service.dart';
|
||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||
import 'package:marco/helpers/services/auth_service.dart';
|
||||
import 'package:marco/helpers/services/notification_action_handler.dart';
|
||||
|
||||
/// Firebase Notification Service
|
||||
class FirebaseNotificationService {
|
||||
final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
|
||||
final Logger _logger = Logger();
|
||||
|
||||
static const _fcmScopes = [
|
||||
'https://www.googleapis.com/auth/firebase.messaging',
|
||||
];
|
||||
|
||||
/// Initialize FCM (Firebase.initializeApp() should be called once globally)
|
||||
Future<void> initialize() async {
|
||||
_logger.i('✅ FirebaseMessaging initializing...');
|
||||
|
||||
await _requestNotificationPermission();
|
||||
_registerMessageListeners();
|
||||
_registerTokenRefreshListener();
|
||||
|
||||
// Fetch token on app start (but only register with server if JWT available)
|
||||
await getFcmToken(registerOnServer: true);
|
||||
}
|
||||
|
||||
/// Request notification permission
|
||||
Future<void> _requestNotificationPermission() async {
|
||||
final settings = await _firebaseMessaging.requestPermission();
|
||||
_logger.i('📩 Permission Status: ${settings.authorizationStatus}');
|
||||
}
|
||||
|
||||
/// Foreground, background, and tap listeners
|
||||
void _registerMessageListeners() {
|
||||
FirebaseMessaging.onMessage.listen((message) {
|
||||
_logger.i('📩 Foreground Notification');
|
||||
_logNotificationDetails(message);
|
||||
|
||||
// Handle custom actions
|
||||
NotificationActionHandler.handle(message.data);
|
||||
|
||||
// Show local notification
|
||||
if (message.notification != null) {
|
||||
LocalNotificationService.showNotification(
|
||||
title: message.notification!.title ?? "No title",
|
||||
body: message.notification!.body ?? "No body",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
|
||||
|
||||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||||
}
|
||||
|
||||
/// Token refresh handler
|
||||
void _registerTokenRefreshListener() {
|
||||
_firebaseMessaging.onTokenRefresh.listen((newToken) async {
|
||||
_logger.i('🔄 Token refreshed: $newToken');
|
||||
if (newToken.isEmpty) return;
|
||||
|
||||
await LocalStorage.setFcmToken(newToken);
|
||||
|
||||
final jwt = await LocalStorage.getJwtToken();
|
||||
if (jwt?.isNotEmpty ?? false) {
|
||||
final success = await AuthService.registerDeviceToken(newToken);
|
||||
_logger.i(success
|
||||
? '✅ Device token updated on server after refresh.'
|
||||
: '⚠️ Failed to update device token on server.');
|
||||
} else {
|
||||
_logger.w('⚠️ JWT not available — will retry after login.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Get current token (optionally sync to server if logged in)
|
||||
Future<String?> getFcmToken({bool registerOnServer = false}) async {
|
||||
try {
|
||||
final token = await _firebaseMessaging.getToken();
|
||||
_logger.i('🔑 FCM token: $token');
|
||||
|
||||
if (token?.isNotEmpty ?? false) {
|
||||
await LocalStorage.setFcmToken(token!);
|
||||
|
||||
if (registerOnServer) {
|
||||
final jwt = await LocalStorage.getJwtToken();
|
||||
if (jwt?.isNotEmpty ?? false) {
|
||||
final success = await AuthService.registerDeviceToken(token);
|
||||
_logger.i(success
|
||||
? '✅ Device token registered on server.'
|
||||
: '⚠️ Failed to register device token on server.');
|
||||
} else {
|
||||
_logger.w('⚠️ JWT not available — skipping server registration.');
|
||||
}
|
||||
}
|
||||
}
|
||||
return token;
|
||||
} catch (e, s) {
|
||||
_logger.e('❌ Failed to get FCM token', error: e, stackTrace: s);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-register token with server (useful after login)
|
||||
Future<void> registerTokenAfterLogin() async {
|
||||
final token = await LocalStorage.getFcmToken();
|
||||
if (token?.isNotEmpty ?? false) {
|
||||
final success = await AuthService.registerDeviceToken(token!);
|
||||
_logger.i(success
|
||||
? "✅ FCM token registered after login."
|
||||
: "⚠️ Failed to register FCM token after login.");
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a test notification using FCM v1 API
|
||||
Future<void> sendTestNotification(String deviceToken) async {
|
||||
try {
|
||||
final client = await _getAuthenticatedHttpClient();
|
||||
if (client == null) return;
|
||||
|
||||
final projectId = await _getProjectId();
|
||||
if (projectId == null) return;
|
||||
|
||||
_logger.i('🏗 Firebase Project ID: $projectId');
|
||||
|
||||
final url = Uri.parse(
|
||||
'https://fcm.googleapis.com/v1/projects/$projectId/messages:send');
|
||||
final payload = _buildNotificationPayload(deviceToken);
|
||||
|
||||
final response = await client.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(payload),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
_logger.i('✅ Test notification sent successfully');
|
||||
} else {
|
||||
_logger.e('❌ Send failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
|
||||
client.close();
|
||||
} catch (e, s) {
|
||||
_logger.e('❌ Error sending notification', error: e, stackTrace: s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticated HTTP client using service account
|
||||
Future<http.Client?> _getAuthenticatedHttpClient() async {
|
||||
try {
|
||||
final credentials = ServiceAccountCredentials.fromJson(
|
||||
json.decode(await rootBundle.loadString('assets/service-account.json')),
|
||||
);
|
||||
return clientViaServiceAccount(credentials, _fcmScopes);
|
||||
} catch (e, s) {
|
||||
_logger.e('❌ Failed to authenticate', error: e, stackTrace: s);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Project ID from service account
|
||||
Future<String?> _getProjectId() async {
|
||||
try {
|
||||
final jsonMap = json
|
||||
.decode(await rootBundle.loadString('assets/service-account.json'));
|
||||
return jsonMap['project_id'];
|
||||
} catch (e) {
|
||||
_logger.e('❌ Failed to load project_id: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build FCM v1 payload
|
||||
Map<String, dynamic> _buildNotificationPayload(String token) => {
|
||||
"message": {
|
||||
"token": token,
|
||||
"notification": {
|
||||
"title": "Test Notification",
|
||||
"body": "This is a test message from Flutter (v1 API)"
|
||||
},
|
||||
"data": {
|
||||
"click_action": "FLUTTER_NOTIFICATION_CLICK",
|
||||
"type": "expense_updated", // Example
|
||||
"expense_id": "1234"
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
/// Handle tap on notification
|
||||
void _handleNotificationTap(RemoteMessage message) {
|
||||
_logger.i('📌 Notification tapped: ${message.data}');
|
||||
NotificationActionHandler.handle(message.data);
|
||||
}
|
||||
|
||||
/// Log notification details
|
||||
void _logNotificationDetails(RemoteMessage message) {
|
||||
_logger
|
||||
..i('🆔 ID: ${message.messageId}')
|
||||
..i('📜 Title: ${message.notification?.title}')
|
||||
..i('📜 Body: ${message.notification?.body}')
|
||||
..i('📦 Data: ${message.data}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Background handler (required by Firebase)
|
||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
final logger = Logger();
|
||||
logger
|
||||
..i('⚡ Handling background notification...')
|
||||
..i('📦 Data: ${message.data}');
|
||||
|
||||
NotificationActionHandler.handle(message.data);
|
||||
}
|
42
lib/helpers/services/local_notification_service.dart
Normal file
42
lib/helpers/services/local_notification_service.dart
Normal file
@ -0,0 +1,42 @@
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
class LocalNotificationService {
|
||||
static final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
static Future<void> initialize() async {
|
||||
const AndroidInitializationSettings androidInitSettings =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
const InitializationSettings initSettings = InitializationSettings(
|
||||
android: androidInitSettings,
|
||||
iOS: DarwinInitializationSettings(),
|
||||
);
|
||||
|
||||
await _notificationsPlugin.initialize(initSettings);
|
||||
}
|
||||
|
||||
static Future<void> showNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
const AndroidNotificationDetails androidDetails =
|
||||
AndroidNotificationDetails(
|
||||
'default_channel_id',
|
||||
'Default Channel',
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const NotificationDetails notificationDetails =
|
||||
NotificationDetails(android: androidDetails);
|
||||
|
||||
await _notificationsPlugin.show(
|
||||
0,
|
||||
title,
|
||||
body,
|
||||
notificationDetails,
|
||||
);
|
||||
}
|
||||
}
|
183
lib/helpers/services/notification_action_handler.dart
Normal file
183
lib/helpers/services/notification_action_handler.dart
Normal file
@ -0,0 +1,183 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
import 'package:marco/controller/dashboard/attendance_screen_controller.dart';
|
||||
import 'package:marco/controller/dashboard/daily_task_controller.dart';
|
||||
import 'package:marco/controller/task_planing/daily_task_planing_controller.dart';
|
||||
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
||||
import 'package:marco/controller/expense/expense_detail_controller.dart';
|
||||
|
||||
/// Handles incoming FCM notification actions and updates UI/controllers.
|
||||
class NotificationActionHandler {
|
||||
static final Logger _logger = Logger();
|
||||
|
||||
/// Main entry point — call this for any notification `data` map.
|
||||
static void handle(Map<String, dynamic> data) {
|
||||
_logger.i('📲 Handling notification action: $data');
|
||||
|
||||
if (data.isEmpty) {
|
||||
_logger.w('⚠️ Empty notification data received.');
|
||||
return;
|
||||
}
|
||||
|
||||
final type = data['type'];
|
||||
final action = data['Action'];
|
||||
final keyword = data['Keyword'];
|
||||
|
||||
if (type != null) {
|
||||
_handleByType(type, data);
|
||||
} else if (keyword != null) {
|
||||
_handleByKeyword(keyword, action, data);
|
||||
} else {
|
||||
_logger.w('⚠️ Unhandled notification: $data');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle notification if identified by `type`
|
||||
static void _handleByType(String type, Map<String, dynamic> data) {
|
||||
switch (type) {
|
||||
case 'expense_updated':
|
||||
// No specific handler yet
|
||||
break;
|
||||
case 'attendance_updated':
|
||||
_handleAttendanceUpdated(data);
|
||||
break;
|
||||
default:
|
||||
_logger.w('⚠️ Unknown notification type: $type');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle notification if identified by `keyword`
|
||||
static void _handleByKeyword(
|
||||
String keyword, String? action, Map<String, dynamic> data) {
|
||||
switch (keyword) {
|
||||
case 'Attendance':
|
||||
if (_isAttendanceAction(action)) {
|
||||
_handleAttendanceUpdated(data);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Report_Task':
|
||||
_handleTaskUpdated(data, isComment: false);
|
||||
break;
|
||||
|
||||
case 'Task_Comment':
|
||||
_handleTaskUpdated(data, isComment: true);
|
||||
break;
|
||||
case 'Expenses_Modified':
|
||||
_handleExpenseUpdated(data);
|
||||
break;
|
||||
|
||||
// ✅ New cases
|
||||
case 'Task_Modified':
|
||||
case 'WorkArea_Modified':
|
||||
case 'Floor_Modified':
|
||||
case 'Building_Modified':
|
||||
_handleTaskPlanningUpdated(data);
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.w('⚠️ Unhandled notification keyword: $keyword');
|
||||
}
|
||||
}
|
||||
|
||||
static void _handleTaskPlanningUpdated(Map<String, dynamic> data) {
|
||||
final projectId = data['ProjectId'];
|
||||
if (projectId == null) {
|
||||
_logger.w("⚠️ TaskPlanning update received without ProjectId: $data");
|
||||
return;
|
||||
}
|
||||
|
||||
_safeControllerUpdate<DailyTaskPlaningController>(
|
||||
onFound: (controller) {
|
||||
controller.fetchTaskData(projectId);
|
||||
},
|
||||
notFoundMessage:
|
||||
'⚠️ DailyTaskPlaningController not found, cannot refresh.',
|
||||
successMessage:
|
||||
'✅ DailyTaskPlaningController refreshed from notification.',
|
||||
);
|
||||
}
|
||||
|
||||
/// Validates the set of allowed Attendance actions
|
||||
static bool _isAttendanceAction(String? action) {
|
||||
const validActions = {
|
||||
'CHECK_IN',
|
||||
'CHECK_OUT',
|
||||
'REQUEST_REGULARIZE',
|
||||
'REQUEST_DELETE',
|
||||
'REGULARIZE',
|
||||
'REGULARIZE_REJECT'
|
||||
};
|
||||
return validActions.contains(action);
|
||||
}
|
||||
|
||||
static void _handleExpenseUpdated(Map<String, dynamic> data) {
|
||||
final expenseId = data['ExpenseId'];
|
||||
if (expenseId == null) {
|
||||
_logger.w("⚠️ Expense update received without ExpenseId: $data");
|
||||
return;
|
||||
}
|
||||
|
||||
// Update Expense List
|
||||
_safeControllerUpdate<ExpenseController>(
|
||||
onFound: (controller) async {
|
||||
await controller.fetchExpenses();
|
||||
},
|
||||
notFoundMessage: '⚠️ ExpenseController not found, cannot refresh list.',
|
||||
successMessage:
|
||||
'✅ ExpenseController refreshed from expense notification.',
|
||||
);
|
||||
|
||||
// Update Expense Detail (if open and matches this expenseId)
|
||||
_safeControllerUpdate<ExpenseDetailController>(
|
||||
onFound: (controller) async {
|
||||
// only refresh if the open screen is for this expense
|
||||
if (controller.expense.value?.id == expenseId) {
|
||||
await controller.fetchExpenseDetails();
|
||||
_logger
|
||||
.i("✅ ExpenseDetailController refreshed for Expense $expenseId");
|
||||
}
|
||||
},
|
||||
notFoundMessage: 'ℹ️ ExpenseDetailController not active, skipping.',
|
||||
successMessage: '✅ ExpenseDetailController checked for refresh.',
|
||||
);
|
||||
}
|
||||
|
||||
static void _handleAttendanceUpdated(Map<String, dynamic> data) {
|
||||
_safeControllerUpdate<AttendanceController>(
|
||||
onFound: (controller) => controller.refreshDataFromNotification(
|
||||
projectId: data['ProjectId'],
|
||||
),
|
||||
notFoundMessage: '⚠️ AttendanceController not found, cannot update.',
|
||||
successMessage: '✅ AttendanceController refreshed from notification.',
|
||||
);
|
||||
}
|
||||
|
||||
static void _handleTaskUpdated(Map<String, dynamic> data,
|
||||
{required bool isComment}) {
|
||||
_safeControllerUpdate<DailyTaskController>(
|
||||
onFound: (controller) => controller.refreshTasksFromNotification(
|
||||
projectId: data['ProjectId'],
|
||||
taskAllocationId: data['TaskAllocationId'],
|
||||
),
|
||||
notFoundMessage: '⚠️ DailyTaskController not found, cannot update.',
|
||||
successMessage: '✅ DailyTaskController refreshed from notification.',
|
||||
);
|
||||
}
|
||||
|
||||
/// Generic reusable method for safe GetX controller access + log handling
|
||||
static void _safeControllerUpdate<T>({
|
||||
required void Function(T controller) onFound,
|
||||
required String notFoundMessage,
|
||||
required String successMessage,
|
||||
}) {
|
||||
try {
|
||||
final controller = Get.find<T>();
|
||||
onFound(controller);
|
||||
_logger.i(successMessage);
|
||||
} catch (e) {
|
||||
_logger.w(notFoundMessage);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,13 +1,12 @@
|
||||
import 'dart:convert';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:marco/controller/project_controller.dart';
|
||||
import 'package:marco/helpers/services/auth_service.dart';
|
||||
import 'package:marco/helpers/services/localizations/language.dart';
|
||||
import 'package:marco/helpers/theme/theme_customizer.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:marco/model/user_permission.dart';
|
||||
import 'package:marco/model/employee_info.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:marco/controller/project_controller.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:marco/model/user_permission.dart';
|
||||
|
||||
class LocalStorage {
|
||||
static const String _loggedInUserKey = "user";
|
||||
@ -19,181 +18,156 @@ class LocalStorage {
|
||||
static const String _employeeInfoKey = "employee_info";
|
||||
static const String _mpinTokenKey = "mpinToken";
|
||||
static const String _isMpinKey = "isMpin";
|
||||
static const String _fcmTokenKey = 'fcm_token';
|
||||
|
||||
static SharedPreferences? _preferencesInstance;
|
||||
|
||||
static SharedPreferences get preferences {
|
||||
if (_preferencesInstance == null) {
|
||||
throw ("Call LocalStorage.init() to initialize local storage");
|
||||
throw ("Call LocalStorage.init() before using it");
|
||||
}
|
||||
return _preferencesInstance!;
|
||||
}
|
||||
// In LocalStorage class
|
||||
|
||||
static Future<bool> setUserPermissions(
|
||||
List<UserPermission> permissions) async {
|
||||
// Convert the list of UserPermission objects to a List<Map<String, dynamic>>
|
||||
final jsonList = permissions.map((e) => e.toJson()).toList();
|
||||
|
||||
// Save as a JSON string
|
||||
return preferences.setString(_userPermissionsKey, jsonEncode(jsonList));
|
||||
}
|
||||
|
||||
static List<UserPermission> getUserPermissions() {
|
||||
final storedJson = preferences.getString(_userPermissionsKey);
|
||||
|
||||
if (storedJson != null) {
|
||||
final List<dynamic> parsedList = jsonDecode(storedJson);
|
||||
return parsedList
|
||||
.map((e) => UserPermission.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
static Future<bool> removeUserPermissions() async {
|
||||
return preferences.remove(_userPermissionsKey);
|
||||
}
|
||||
|
||||
// Store EmployeeInfo
|
||||
static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) async {
|
||||
final jsonData = employeeInfo.toJson();
|
||||
return preferences.setString(_employeeInfoKey, jsonEncode(jsonData));
|
||||
}
|
||||
|
||||
static EmployeeInfo? getEmployeeInfo() {
|
||||
final storedJson = preferences.getString(_employeeInfoKey);
|
||||
if (storedJson != null) {
|
||||
final Map<String, dynamic> json = jsonDecode(storedJson);
|
||||
return EmployeeInfo.fromJson(json);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<bool> removeEmployeeInfo() async {
|
||||
return preferences.remove(_employeeInfoKey);
|
||||
}
|
||||
|
||||
// Other methods for handling JWT, refresh token, etc.
|
||||
/// Initialization
|
||||
static Future<void> init() async {
|
||||
_preferencesInstance = await SharedPreferences.getInstance();
|
||||
await initData();
|
||||
}
|
||||
|
||||
static Future<void> initData() async {
|
||||
SharedPreferences preferences = await SharedPreferences.getInstance();
|
||||
AuthService.isLoggedIn = preferences.getBool(_loggedInUserKey) ?? false;
|
||||
ThemeCustomizer.fromJSON(preferences.getString(_themeCustomizerKey));
|
||||
}
|
||||
|
||||
static Future<bool> setLoggedInUser(bool loggedIn) async {
|
||||
return preferences.setBool(_loggedInUserKey, loggedIn);
|
||||
/// ================== User Permissions ==================
|
||||
static Future<bool> setUserPermissions(
|
||||
List<UserPermission> permissions) async {
|
||||
final jsonList = permissions.map((e) => e.toJson()).toList();
|
||||
return preferences.setString(_userPermissionsKey, jsonEncode(jsonList));
|
||||
}
|
||||
|
||||
static Future<bool> setCustomizer(ThemeCustomizer themeCustomizer) {
|
||||
return preferences.setString(_themeCustomizerKey, themeCustomizer.toJSON());
|
||||
static List<UserPermission> getUserPermissions() {
|
||||
final storedJson = preferences.getString(_userPermissionsKey);
|
||||
if (storedJson == null) return [];
|
||||
return (jsonDecode(storedJson) as List)
|
||||
.map((e) => UserPermission.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Future<bool> setLanguage(Language language) {
|
||||
return preferences.setString(_languageKey, language.locale.languageCode);
|
||||
static Future<bool> removeUserPermissions() =>
|
||||
preferences.remove(_userPermissionsKey);
|
||||
|
||||
/// ================== Employee Info ==================
|
||||
static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) => preferences
|
||||
.setString(_employeeInfoKey, jsonEncode(employeeInfo.toJson()));
|
||||
|
||||
static EmployeeInfo? getEmployeeInfo() {
|
||||
final storedJson = preferences.getString(_employeeInfoKey);
|
||||
return storedJson == null
|
||||
? null
|
||||
: EmployeeInfo.fromJson(jsonDecode(storedJson));
|
||||
}
|
||||
|
||||
static String? getLanguage() {
|
||||
return preferences.getString(_languageKey);
|
||||
static Future<bool> removeEmployeeInfo() =>
|
||||
preferences.remove(_employeeInfoKey);
|
||||
|
||||
/// ================== Login / Logout ==================
|
||||
static Future<bool> setLoggedInUser(bool loggedIn) =>
|
||||
preferences.setBool(_loggedInUserKey, loggedIn);
|
||||
|
||||
static Future<bool> removeLoggedInUser() =>
|
||||
preferences.remove(_loggedInUserKey);
|
||||
|
||||
static Future<void> logout() async {
|
||||
try {
|
||||
final refreshToken = getRefreshToken();
|
||||
final fcmToken = getFcmToken();
|
||||
|
||||
// Call API only if both tokens exist
|
||||
if (refreshToken != null && fcmToken != null) {
|
||||
await AuthService.logoutApi(refreshToken, fcmToken);
|
||||
}
|
||||
} catch (e) {
|
||||
print("Logout API error: $e");
|
||||
}
|
||||
|
||||
// ===== Local Cleanup =====
|
||||
await removeLoggedInUser();
|
||||
await removeToken(_jwtTokenKey);
|
||||
await removeToken(_refreshTokenKey);
|
||||
await removeUserPermissions();
|
||||
await removeEmployeeInfo();
|
||||
await removeMpinToken();
|
||||
await removeIsMpin();
|
||||
await preferences.remove("mpin_verified");
|
||||
await preferences.remove(_languageKey);
|
||||
await preferences.remove(_themeCustomizerKey);
|
||||
await preferences.remove('selectedProjectId');
|
||||
|
||||
if (Get.isRegistered<ProjectController>()) {
|
||||
Get.find<ProjectController>().clearProjects();
|
||||
}
|
||||
|
||||
Get.offAllNamed('/auth/login-option');
|
||||
}
|
||||
|
||||
static Future<bool> removeLoggedInUser() async {
|
||||
return preferences.remove(_loggedInUserKey);
|
||||
}
|
||||
/// ================== Theme & Language ==================
|
||||
static Future<bool> setCustomizer(ThemeCustomizer themeCustomizer) =>
|
||||
preferences.setString(_themeCustomizerKey, themeCustomizer.toJSON());
|
||||
|
||||
// Add methods to handle JWT and Refresh Token
|
||||
static Future<bool> setToken(String key, String token) {
|
||||
return preferences.setString(key, token);
|
||||
}
|
||||
static Future<bool> setLanguage(Language language) =>
|
||||
preferences.setString(_languageKey, language.locale.languageCode);
|
||||
|
||||
static String? getToken(String key) {
|
||||
return preferences.getString(key);
|
||||
}
|
||||
static String? getLanguage() => preferences.getString(_languageKey);
|
||||
|
||||
static Future<bool> removeToken(String key) {
|
||||
return preferences.remove(key);
|
||||
}
|
||||
/// ================== Tokens ==================
|
||||
static Future<bool> setToken(String key, String token) =>
|
||||
preferences.setString(key, token);
|
||||
|
||||
// Convenience methods for getting the JWT and Refresh tokens
|
||||
static String? getJwtToken() {
|
||||
return getToken(_jwtTokenKey);
|
||||
}
|
||||
static String? getToken(String key) => preferences.getString(key);
|
||||
|
||||
static String? getRefreshToken() {
|
||||
return getToken(_refreshTokenKey);
|
||||
}
|
||||
static Future<bool> removeToken(String key) => preferences.remove(key);
|
||||
|
||||
static Future<bool> setJwtToken(String jwtToken) {
|
||||
return setToken(_jwtTokenKey, jwtToken);
|
||||
}
|
||||
static Future<bool> setJwtToken(String jwtToken) =>
|
||||
setToken(_jwtTokenKey, jwtToken);
|
||||
|
||||
static Future<bool> setRefreshToken(String refreshToken) {
|
||||
return setToken(_refreshTokenKey, refreshToken);
|
||||
}
|
||||
static Future<bool> setRefreshToken(String refreshToken) =>
|
||||
setToken(_refreshTokenKey, refreshToken);
|
||||
|
||||
static Future<void> logout() async {
|
||||
await removeLoggedInUser();
|
||||
await removeToken(_jwtTokenKey);
|
||||
await removeToken(_refreshTokenKey);
|
||||
await removeUserPermissions();
|
||||
await removeEmployeeInfo();
|
||||
await removeMpinToken();
|
||||
await removeIsMpin();
|
||||
await preferences.remove("mpin_verified");
|
||||
await preferences.remove(_languageKey);
|
||||
await preferences.remove(_themeCustomizerKey);
|
||||
await preferences.remove('selectedProjectId');
|
||||
if (Get.isRegistered<ProjectController>()) {
|
||||
Get.find<ProjectController>().clearProjects();
|
||||
}
|
||||
|
||||
Get.offAllNamed('/auth/login-option');
|
||||
}
|
||||
|
||||
static Future<bool> setMpinToken(String token) {
|
||||
return preferences.setString(_mpinTokenKey, token);
|
||||
}
|
||||
|
||||
static String? getMpinToken() {
|
||||
return preferences.getString(_mpinTokenKey);
|
||||
}
|
||||
|
||||
static Future<bool> removeMpinToken() {
|
||||
return preferences.remove(_mpinTokenKey);
|
||||
}
|
||||
|
||||
// MPIN Enabled flag
|
||||
static Future<bool> setIsMpin(bool value) {
|
||||
return preferences.setBool(_isMpinKey, value);
|
||||
}
|
||||
|
||||
static bool getIsMpin() {
|
||||
return preferences.getBool(_isMpinKey) ?? false;
|
||||
}
|
||||
|
||||
static Future<bool> removeIsMpin() {
|
||||
return preferences.remove(_isMpinKey);
|
||||
}
|
||||
|
||||
static Future<bool> setBool(String key, bool value) async {
|
||||
return preferences.setBool(key, value);
|
||||
}
|
||||
|
||||
static bool? getBool(String key) {
|
||||
return preferences.getBool(key);
|
||||
}
|
||||
// Save and retrieve String values
|
||||
static String? getString(String key) {
|
||||
return preferences.getString(key);
|
||||
}
|
||||
|
||||
static Future<bool> saveString(String key, String value) async {
|
||||
return preferences.setString(key, value);
|
||||
}
|
||||
static String? getJwtToken() => getToken(_jwtTokenKey);
|
||||
|
||||
static String? getRefreshToken() => getToken(_refreshTokenKey);
|
||||
|
||||
/// ================== FCM Token ==================
|
||||
static Future<void> setFcmToken(String token) =>
|
||||
preferences.setString(_fcmTokenKey, token);
|
||||
|
||||
static String? getFcmToken() => preferences.getString(_fcmTokenKey);
|
||||
|
||||
/// ================== MPIN ==================
|
||||
static Future<bool> setMpinToken(String token) =>
|
||||
preferences.setString(_mpinTokenKey, token);
|
||||
|
||||
static String? getMpinToken() => preferences.getString(_mpinTokenKey);
|
||||
|
||||
static Future<bool> removeMpinToken() => preferences.remove(_mpinTokenKey);
|
||||
|
||||
static Future<bool> setIsMpin(bool value) =>
|
||||
preferences.setBool(_isMpinKey, value);
|
||||
|
||||
static bool getIsMpin() => preferences.getBool(_isMpinKey) ?? false;
|
||||
|
||||
static Future<bool> removeIsMpin() => preferences.remove(_isMpinKey);
|
||||
|
||||
/// ================== Generic Set/Get ==================
|
||||
static Future<bool> setBool(String key, bool value) =>
|
||||
preferences.setBool(key, value);
|
||||
|
||||
static bool? getBool(String key) => preferences.getBool(key);
|
||||
|
||||
static String? getString(String key) => preferences.getString(key);
|
||||
|
||||
static Future<bool> saveString(String key, String value) =>
|
||||
preferences.setString(key, value);
|
||||
}
|
||||
|
@ -5,6 +5,8 @@ class DateTimeUtils {
|
||||
/// Converts a UTC datetime string to local time and formats it.
|
||||
static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) {
|
||||
try {
|
||||
logSafe('Received UTC string: $utcTimeString'); // 🔹 Log input
|
||||
|
||||
final parsed = DateTime.parse(utcTimeString);
|
||||
final utcDateTime = DateTime.utc(
|
||||
parsed.year,
|
||||
@ -21,9 +23,13 @@ class DateTimeUtils {
|
||||
|
||||
final formatted = _formatDateTime(localDateTime, format: format);
|
||||
|
||||
logSafe('Converted Local DateTime: $localDateTime'); // 🔹 Log raw local datetime
|
||||
logSafe('Formatted Local DateTime: $formatted'); // 🔹 Log formatted string
|
||||
|
||||
return formatted;
|
||||
} catch (e, stackTrace) {
|
||||
logSafe('DateTime conversion failed: $e', error: e, stackTrace: stackTrace);
|
||||
logSafe('DateTime conversion failed: $e',
|
||||
error: e, stackTrace: stackTrace);
|
||||
return 'Invalid Date';
|
||||
}
|
||||
}
|
||||
@ -31,7 +37,9 @@ class DateTimeUtils {
|
||||
/// Public utility for formatting any DateTime.
|
||||
static String formatDate(DateTime date, String format) {
|
||||
try {
|
||||
return DateFormat(format).format(date);
|
||||
final formatted = DateFormat(format).format(date);
|
||||
logSafe('Formatted DateTime ($date) => $formatted'); // 🔹 Log input/output
|
||||
return formatted;
|
||||
} catch (e, stackTrace) {
|
||||
logSafe('formatDate failed: $e', error: e, stackTrace: stackTrace);
|
||||
return 'Invalid Date';
|
||||
|
271
lib/helpers/utils/validators.dart
Normal file
271
lib/helpers/utils/validators.dart
Normal file
@ -0,0 +1,271 @@
|
||||
// lib/utils/validators.dart
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Common validators for Indian IDs, payments, and typical form fields.
|
||||
class Validators {
|
||||
// -----------------------------
|
||||
// Regexes (compiled once)
|
||||
// -----------------------------
|
||||
static final RegExp _panRegex = RegExp(r'^[A-Z]{5}[0-9]{4}[A-Z]$');
|
||||
// GSTIN: 2-digit/valid state code, PAN, entity code (1-9A-Z), 'Z', checksum (0-9A-Z)
|
||||
static final RegExp _gstRegex = RegExp(
|
||||
r'^(0[1-9]|1[0-9]|2[0-9]|3[0-7])[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$',
|
||||
);
|
||||
// Aadhaar digits only
|
||||
static final RegExp _aadhaarRegex = RegExp(r'^[2-9]\d{11}$');
|
||||
// Name (letters + spaces + dots + hyphen/apostrophe)
|
||||
static final RegExp _nameRegex = RegExp(r"^[A-Za-z][A-Za-z .'\-]{1,49}$");
|
||||
// Email (generic)
|
||||
static final RegExp _emailRegex =
|
||||
RegExp(r"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$");
|
||||
// Indian mobile
|
||||
static final RegExp _mobileRegex = RegExp(r'^[6-9]\d{9}$');
|
||||
// Pincode (India: 6 digits starting 1–9)
|
||||
static final RegExp _pincodeRegex = RegExp(r'^[1-9][0-9]{5}$');
|
||||
// IFSC (4 letters + 0 + 6 alphanumeric)
|
||||
static final RegExp _ifscRegex = RegExp(r'^[A-Z]{4}0[A-Z0-9]{6}$');
|
||||
// Bank account number (9–18 digits)
|
||||
static final RegExp _bankAccountRegex = RegExp(r'^\d{9,18}$');
|
||||
// UPI ID (name@bank, simple check)
|
||||
static final RegExp _upiRegex =
|
||||
RegExp(r'^[\w.\-]{2,}@[\w]{2,}$', caseSensitive: false);
|
||||
// Strong password (8+ chars, upper, lower, digit, special)
|
||||
static final RegExp _passwordRegex =
|
||||
RegExp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$');
|
||||
// Date dd/mm/yyyy (basic validation)
|
||||
static final RegExp _dateRegex =
|
||||
RegExp(r'^([0-2][0-9]|3[0-1])/(0[1-9]|1[0-2])/[0-9]{4}$');
|
||||
// URL
|
||||
static final RegExp _urlRegex = RegExp(
|
||||
r'^(https?:\/\/)?([a-zA-Z0-9.-]+)\.[a-zA-Z]{2,}(:\d+)?(\/\S*)?$');
|
||||
// Transaction ID (alphanumeric, dashes/underscores, 8–36 chars)
|
||||
static final RegExp _transactionIdRegex =
|
||||
RegExp(r'^[A-Za-z0-9\-_]{8,36}$');
|
||||
|
||||
// -----------------------------
|
||||
// PAN
|
||||
// -----------------------------
|
||||
static bool isValidPAN(String? input) {
|
||||
if (input == null) return false;
|
||||
return _panRegex.hasMatch(input.trim().toUpperCase());
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// GSTIN
|
||||
// -----------------------------
|
||||
static bool isValidGSTIN(String? input) {
|
||||
if (input == null) return false;
|
||||
return _gstRegex.hasMatch(_compact(input).toUpperCase());
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Aadhaar
|
||||
// -----------------------------
|
||||
static bool isValidAadhaar(String? input, {bool enforceChecksum = true}) {
|
||||
if (input == null) return false;
|
||||
final a = _digitsOnly(input);
|
||||
if (!_aadhaarRegex.hasMatch(a)) return false;
|
||||
return enforceChecksum ? _verhoeffValidate(a) : true;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Mobile
|
||||
// -----------------------------
|
||||
static bool isValidIndianMobile(String? input) {
|
||||
if (input == null) return false;
|
||||
final s = _digitsOnly(input.replaceFirst(RegExp(r'^(?:\+?91|0)'), ''));
|
||||
return _mobileRegex.hasMatch(s);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Email
|
||||
// -----------------------------
|
||||
static bool isValidEmail(String? input, {bool gmailOnly = false}) {
|
||||
if (input == null) return false;
|
||||
final e = input.trim();
|
||||
if (!_emailRegex.hasMatch(e)) return false;
|
||||
if (!gmailOnly) return true;
|
||||
final domain = e.split('@').last.toLowerCase();
|
||||
return domain == 'gmail.com' || domain == 'googlemail.com';
|
||||
}
|
||||
|
||||
static bool isValidGmail(String? input) =>
|
||||
isValidEmail(input, gmailOnly: true);
|
||||
|
||||
// -----------------------------
|
||||
// Name
|
||||
// -----------------------------
|
||||
static bool isValidName(String? input, {int minLen = 2, int maxLen = 50}) {
|
||||
if (input == null) return false;
|
||||
final s = input.trim();
|
||||
if (s.length < minLen || s.length > maxLen) return false;
|
||||
return _nameRegex.hasMatch(s);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Transaction ID
|
||||
// -----------------------------
|
||||
static bool isValidTransactionId(String? input) {
|
||||
if (input == null) return false;
|
||||
return _transactionIdRegex.hasMatch(input.trim());
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Other fields
|
||||
// -----------------------------
|
||||
static bool isValidPincode(String? input) =>
|
||||
input != null && _pincodeRegex.hasMatch(input.trim());
|
||||
|
||||
static bool isValidIFSC(String? input) =>
|
||||
input != null && _ifscRegex.hasMatch(input.trim().toUpperCase());
|
||||
|
||||
static bool isValidBankAccount(String? input) =>
|
||||
input != null && _bankAccountRegex.hasMatch(_digitsOnly(input));
|
||||
|
||||
static bool isValidUPI(String? input) =>
|
||||
input != null && _upiRegex.hasMatch(input.trim());
|
||||
|
||||
static bool isValidPassword(String? input) =>
|
||||
input != null && _passwordRegex.hasMatch(input.trim());
|
||||
|
||||
static bool isValidDate(String? input) =>
|
||||
input != null && _dateRegex.hasMatch(input.trim());
|
||||
|
||||
static bool isValidURL(String? input) =>
|
||||
input != null && _urlRegex.hasMatch(input.trim());
|
||||
|
||||
// -----------------------------
|
||||
// Numbers
|
||||
// -----------------------------
|
||||
static bool isInt(String? input) =>
|
||||
input != null && int.tryParse(input.trim()) != null;
|
||||
|
||||
static bool isDouble(String? input) =>
|
||||
input != null && double.tryParse(input.trim()) != null;
|
||||
|
||||
static bool isNumeric(String? input) => isInt(input) || isDouble(input);
|
||||
|
||||
static bool isInRange(num? value,
|
||||
{num? min, num? max, bool inclusive = true}) {
|
||||
if (value == null) return false;
|
||||
if (min != null && (inclusive ? value < min : value <= min)) return false;
|
||||
if (max != null && (inclusive ? value > max : value >= max)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Flutter-friendly validator lambdas (return null when valid)
|
||||
// -----------------------------
|
||||
static String? requiredField(String? v, {String fieldName = 'This field'}) =>
|
||||
(v == null || v.trim().isEmpty) ? '$fieldName is required' : null;
|
||||
|
||||
static String? panValidator(String? v) =>
|
||||
isValidPAN(v) ? null : 'Enter a valid PAN (e.g., ABCDE1234F)';
|
||||
|
||||
static String? gstValidator(String? v, {bool optional = false}) {
|
||||
if (optional && (v == null || v.trim().isEmpty)) return null;
|
||||
return isValidGSTIN(v) ? null : 'Enter a valid GSTIN';
|
||||
}
|
||||
|
||||
static String? aadhaarValidator(String? v) =>
|
||||
isValidAadhaar(v) ? null : 'Enter a valid Aadhaar (12 digits)';
|
||||
|
||||
static String? mobileValidator(String? v) =>
|
||||
isValidIndianMobile(v) ? null : 'Enter a valid 10-digit mobile';
|
||||
|
||||
static String? emailValidator(String? v, {bool gmailOnly = false}) =>
|
||||
isValidEmail(v, gmailOnly: gmailOnly)
|
||||
? null
|
||||
: gmailOnly
|
||||
? 'Enter a valid Gmail address'
|
||||
: 'Enter a valid email address';
|
||||
|
||||
static String? nameValidator(String? v, {int minLen = 2, int maxLen = 50}) =>
|
||||
isValidName(v, minLen: minLen, maxLen: maxLen)
|
||||
? null
|
||||
: 'Enter a valid name ($minLen–$maxLen chars)';
|
||||
|
||||
static String? transactionIdValidator(String? v) =>
|
||||
isValidTransactionId(v)
|
||||
? null
|
||||
: 'Enter a valid Transaction ID (8–36 chars, letters/numbers)';
|
||||
|
||||
static String? pincodeValidator(String? v) =>
|
||||
isValidPincode(v) ? null : 'Enter a valid 6-digit pincode';
|
||||
|
||||
static String? ifscValidator(String? v) =>
|
||||
isValidIFSC(v) ? null : 'Enter a valid IFSC code';
|
||||
|
||||
static String? bankAccountValidator(String? v) =>
|
||||
isValidBankAccount(v) ? null : 'Enter a valid bank account (9–18 digits)';
|
||||
|
||||
static String? upiValidator(String? v) =>
|
||||
isValidUPI(v) ? null : 'Enter a valid UPI ID';
|
||||
|
||||
static String? passwordValidator(String? v) =>
|
||||
isValidPassword(v)
|
||||
? null
|
||||
: 'Password must be 8+ chars with upper, lower, digit, special';
|
||||
|
||||
static String? dateValidator(String? v) =>
|
||||
isValidDate(v) ? null : 'Enter date in dd/mm/yyyy format';
|
||||
|
||||
static String? urlValidator(String? v) =>
|
||||
isValidURL(v) ? null : 'Enter a valid URL';
|
||||
|
||||
// -----------------------------
|
||||
// Helpers
|
||||
// -----------------------------
|
||||
static String _digitsOnly(String s) => s.replaceAll(RegExp(r'\D'), '');
|
||||
static String _compact(String s) => s.replaceAll(RegExp(r'\s'), '');
|
||||
|
||||
// -----------------------------
|
||||
// Verhoeff checksum (for Aadhaar)
|
||||
// -----------------------------
|
||||
static const List<List<int>> _verhoeffD = [
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
[1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
|
||||
[2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
|
||||
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
|
||||
[4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
|
||||
[5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
|
||||
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
|
||||
[7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
|
||||
[8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
|
||||
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
|
||||
];
|
||||
static const List<List<int>> _verhoeffP = [
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
[1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
|
||||
[5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
|
||||
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
|
||||
[9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
|
||||
[4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
|
||||
[2, 7, 9, 3, 8, 0, 5, 4, 1, 6],
|
||||
[7, 0, 4, 6, 9, 1, 2, 3, 5, 8],
|
||||
];
|
||||
|
||||
static bool _verhoeffValidate(String numStr) {
|
||||
int c = 0;
|
||||
final rev = numStr.split('').reversed.map(int.parse).toList();
|
||||
for (int i = 0; i < rev.length; i++) {
|
||||
c = _verhoeffD[c][_verhoeffP[(i % 8)][rev[i]]];
|
||||
}
|
||||
return c == 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Common input formatters/masks useful in TextFields.
|
||||
class InputFormatters {
|
||||
static final digitsOnly = FilteringTextInputFormatter.digitsOnly;
|
||||
static final upperAlnum =
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[A-Z0-9]'));
|
||||
static final upperLetters =
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[A-Z]'));
|
||||
static final name =
|
||||
FilteringTextInputFormatter.allow(RegExp(r"[A-Za-z .'\-]"));
|
||||
static final alnumWithSpace =
|
||||
FilteringTextInputFormatter.allow(RegExp(r"[A-Za-z0-9 ]"));
|
||||
static LengthLimitingTextInputFormatter maxLen(int n) =>
|
||||
LengthLimitingTextInputFormatter(n);
|
||||
}
|
@ -6,9 +6,9 @@ import 'package:marco/controller/expense/expense_screen_controller.dart';
|
||||
import 'package:marco/helpers/utils/date_time_utils.dart';
|
||||
import 'package:marco/helpers/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';
|
||||
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
||||
|
||||
class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final ProjectController projectController;
|
||||
@ -251,99 +251,20 @@ class ExpenseList extends StatelessWidget {
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
builder: (_) => ConfirmDialog(
|
||||
title: "Delete Expense",
|
||||
message: "Are you sure you want to delete this draft expense?",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
icon: Icons.delete_forever,
|
||||
confirmColor: Colors.redAccent,
|
||||
onConfirm: () async {
|
||||
await controller.deleteExpense(expense.id);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -363,7 +284,7 @@ class ExpenseList extends StatelessWidget {
|
||||
final expense = expenseList[index];
|
||||
final formattedDate = DateTimeUtils.convertUtcToLocal(
|
||||
expense.transactionDate.toIso8601String(),
|
||||
format: 'dd MMM yyyy, hh:mm a',
|
||||
format: 'dd MMM yyyy',
|
||||
);
|
||||
|
||||
return Material(
|
||||
@ -391,7 +312,7 @@ class ExpenseList extends StatelessWidget {
|
||||
fontWeight: 600),
|
||||
Row(
|
||||
children: [
|
||||
MyText.bodyMedium('₹ ${expense.formattedAmount}',
|
||||
MyText.bodyMedium('${expense.formattedAmount}',
|
||||
fontWeight: 600),
|
||||
if (expense.status.name.toLowerCase() == 'draft') ...[
|
||||
const SizedBox(width: 8),
|
||||
|
174
lib/helpers/widgets/my_confirmation_dialog.dart
Normal file
174
lib/helpers/widgets/my_confirmation_dialog.dart
Normal file
@ -0,0 +1,174 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
|
||||
class ConfirmDialog extends StatelessWidget {
|
||||
final String title;
|
||||
final String message;
|
||||
final String confirmText;
|
||||
final String cancelText;
|
||||
final IconData icon;
|
||||
final Color confirmColor;
|
||||
final Future<void> Function() onConfirm;
|
||||
final RxBool? isProcessing;
|
||||
|
||||
const ConfirmDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.onConfirm,
|
||||
this.confirmText = "Delete",
|
||||
this.cancelText = "Cancel",
|
||||
this.icon = Icons.delete,
|
||||
this.confirmColor = Colors.redAccent,
|
||||
this.isProcessing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use provided RxBool, or create one internally
|
||||
final RxBool loading = isProcessing ?? false.obs;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
||||
child: _ContentView(
|
||||
title: title,
|
||||
message: message,
|
||||
icon: icon,
|
||||
confirmColor: confirmColor,
|
||||
confirmText: confirmText,
|
||||
cancelText: cancelText,
|
||||
loading: loading,
|
||||
onConfirm: onConfirm,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContentView extends StatelessWidget {
|
||||
final String title, message, confirmText, cancelText;
|
||||
final IconData icon;
|
||||
final Color confirmColor;
|
||||
final RxBool loading;
|
||||
final Future<void> Function() onConfirm;
|
||||
|
||||
const _ContentView({
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.icon,
|
||||
required this.confirmColor,
|
||||
required this.confirmText,
|
||||
required this.cancelText,
|
||||
required this.loading,
|
||||
required this.onConfirm,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 48, color: confirmColor),
|
||||
const SizedBox(height: 16),
|
||||
MyText.titleLarge(
|
||||
title,
|
||||
fontWeight: 600,
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
MyText.bodySmall(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Obx(() => _DialogButton(
|
||||
text: cancelText,
|
||||
icon: Icons.close,
|
||||
color: Colors.grey,
|
||||
isLoading: false,
|
||||
onPressed: loading.value
|
||||
? null // disable while loading
|
||||
: () => Navigator.pop(context, false),
|
||||
)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Obx(() => _DialogButton(
|
||||
text: confirmText,
|
||||
icon: Icons.delete_forever,
|
||||
color: confirmColor,
|
||||
isLoading: loading.value,
|
||||
onPressed: () async {
|
||||
try {
|
||||
loading.value = true;
|
||||
await onConfirm(); // 🔥 call API
|
||||
Navigator.pop(context, true); // close on success
|
||||
} catch (e) {
|
||||
// Show error, dialog stays open
|
||||
Get.snackbar("Error", "Failed to delete. Try again.");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DialogButton extends StatelessWidget {
|
||||
final String text;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isLoading;
|
||||
|
||||
const _DialogButton({
|
||||
required this.text,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ElevatedButton.icon(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
icon: isLoading
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Icon(icon, color: Colors.white),
|
||||
label: MyText.bodyMedium(
|
||||
isLoading ? "Submitting.." : text,
|
||||
color: Colors.white,
|
||||
fontWeight: 600,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -19,7 +19,7 @@ Future<void> main() async {
|
||||
logSafe("App initialized successfully.");
|
||||
|
||||
runApp(
|
||||
ChangeNotifierProvider<AppNotifier>(
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => AppNotifier(),
|
||||
child: const MainWrapper(),
|
||||
),
|
||||
@ -31,24 +31,21 @@ Future<void> main() async {
|
||||
error: e,
|
||||
stackTrace: stacktrace,
|
||||
);
|
||||
runApp(_buildErrorApp());
|
||||
}
|
||||
}
|
||||
|
||||
runApp(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: Text(
|
||||
"Failed to initialize the app.",
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
Widget _buildErrorApp() => const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: Text(
|
||||
"Failed to initialize the app.",
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// This widget listens to connectivity changes and switches between
|
||||
/// `MyApp` and `OfflineScreen` automatically.
|
||||
class MainWrapper extends StatefulWidget {
|
||||
const MainWrapper({super.key});
|
||||
|
||||
@ -57,7 +54,6 @@ class MainWrapper extends StatefulWidget {
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@ -65,38 +61,21 @@ class _MainWrapperState extends State<MainWrapper> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeConnectivity();
|
||||
// Listen for changes, the callback now provides a List<ConnectivityResult>
|
||||
_connectivity.onConnectivityChanged
|
||||
.listen((List<ConnectivityResult> results) {
|
||||
setState(() {
|
||||
_connectivityStatus = results;
|
||||
});
|
||||
_connectivity.onConnectivityChanged.listen((results) {
|
||||
setState(() => _connectivityStatus = results);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initializeConnectivity() async {
|
||||
// checkConnectivity() now returns a List<ConnectivityResult>
|
||||
final result = await _connectivity.checkConnectivity();
|
||||
setState(() {
|
||||
_connectivityStatus = result;
|
||||
});
|
||||
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();
|
||||
final bool isOffline = _connectivityStatus.contains(ConnectivityResult.none);
|
||||
return isOffline
|
||||
? const MaterialApp(debugShowCheckedModeBanner: false, home: OfflineScreen())
|
||||
: const MyApp();
|
||||
}
|
||||
}
|
||||
|
@ -66,7 +66,9 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return null;
|
||||
} else if (selected.isAfter(now)) {
|
||||
}
|
||||
|
||||
if (selected.isAfter(now)) {
|
||||
showAppSnackbar(
|
||||
title: "Invalid Time",
|
||||
message: "Future time is not allowed.",
|
||||
@ -104,15 +106,16 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
||||
action = 0;
|
||||
actionText = ButtonActions.checkIn;
|
||||
break;
|
||||
case 1:
|
||||
final isOld = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2);
|
||||
final isOldCheckout = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2);
|
||||
|
||||
if (widget.employee.checkOut == null && isOld) {
|
||||
case 1:
|
||||
final isOldCheckIn = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2);
|
||||
final isOldCheckOut = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2);
|
||||
|
||||
if (widget.employee.checkOut == null && isOldCheckIn) {
|
||||
action = 2;
|
||||
actionText = ButtonActions.requestRegularize;
|
||||
imageCapture = false;
|
||||
} else if (widget.employee.checkOut != null && isOldCheckout) {
|
||||
} else if (widget.employee.checkOut != null && isOldCheckOut) {
|
||||
action = 2;
|
||||
actionText = ButtonActions.requestRegularize;
|
||||
} else {
|
||||
@ -120,10 +123,12 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
||||
actionText = ButtonActions.checkOut;
|
||||
}
|
||||
break;
|
||||
|
||||
case 2:
|
||||
action = 2;
|
||||
actionText = ButtonActions.requestRegularize;
|
||||
break;
|
||||
|
||||
default:
|
||||
action = 0;
|
||||
actionText = "Unknown Action";
|
||||
@ -148,25 +153,26 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
||||
}
|
||||
}
|
||||
|
||||
final comment = await _showCommentBottomSheet(context, actionText);
|
||||
final comment = await _showCommentBottomSheet(
|
||||
context,
|
||||
actionText,
|
||||
selectedTime: selectedTime,
|
||||
checkInDate: widget.employee.checkIn,
|
||||
);
|
||||
if (comment == null || comment.isEmpty) {
|
||||
controller.uploadingStates[uniqueLogKey]?.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
bool success = false;
|
||||
String? markTime;
|
||||
|
||||
if (actionText == ButtonActions.requestRegularize) {
|
||||
selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!);
|
||||
if (selectedTime != null) {
|
||||
markTime = DateFormat("hh:mm a").format(selectedTime);
|
||||
}
|
||||
markTime = selectedTime != null ? DateFormat("hh:mm a").format(selectedTime) : null;
|
||||
} else if (selectedTime != null) {
|
||||
markTime = DateFormat("hh:mm a").format(selectedTime);
|
||||
}
|
||||
|
||||
success = await controller.captureAndUploadAttendance(
|
||||
final success = await controller.captureAndUploadAttendance(
|
||||
widget.employee.id,
|
||||
widget.employee.employeeId,
|
||||
selectedProjectId,
|
||||
@ -187,8 +193,8 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
||||
controller.uploadingStates[uniqueLogKey]?.value = false;
|
||||
|
||||
if (success) {
|
||||
controller.fetchEmployeesByProject(selectedProjectId);
|
||||
controller.fetchAttendanceLogs(selectedProjectId);
|
||||
await controller.fetchEmployeesByProject(selectedProjectId);
|
||||
await controller.fetchAttendanceLogs(selectedProjectId);
|
||||
await controller.fetchRegularizationLogs(selectedProjectId);
|
||||
await controller.fetchProjectData(selectedProjectId);
|
||||
controller.update();
|
||||
@ -199,13 +205,13 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
final controller = widget.attendanceController;
|
||||
|
||||
final isUploading = controller.uploadingStates[uniqueLogKey]?.value ?? false;
|
||||
final emp = widget.employee;
|
||||
|
||||
final isYesterday = AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut);
|
||||
final isTodayApproved = AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn);
|
||||
final isApprovedButNotToday = AttendanceButtonHelper.isApprovedButNotToday(emp.activity, isTodayApproved);
|
||||
final isApprovedButNotToday =
|
||||
AttendanceButtonHelper.isApprovedButNotToday(emp.activity, isTodayApproved);
|
||||
|
||||
final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled(
|
||||
isUploading: isUploading,
|
||||
@ -283,7 +289,8 @@ class AttendanceActionButtonUI extends StatelessWidget {
|
||||
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()))
|
||||
if (['approved', 'rejected', 'requested']
|
||||
.contains(buttonText.toLowerCase()))
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
@ -299,10 +306,22 @@ class AttendanceActionButtonUI extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _showCommentBottomSheet(BuildContext context, String actionText) async {
|
||||
Future<String?> _showCommentBottomSheet(
|
||||
BuildContext context,
|
||||
String actionText, {
|
||||
DateTime? selectedTime,
|
||||
DateTime? checkInDate,
|
||||
}) async {
|
||||
final commentController = TextEditingController();
|
||||
String? errorText;
|
||||
|
||||
// Prepare title
|
||||
String sheetTitle = "Add Comment for ${capitalizeFirstLetter(actionText)}";
|
||||
if (selectedTime != null && checkInDate != null) {
|
||||
sheetTitle =
|
||||
"${capitalizeFirstLetter(actionText)} for ${DateFormat('dd MMM yyyy').format(checkInDate)} at ${DateFormat('hh:mm a').format(selectedTime)}";
|
||||
}
|
||||
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
@ -325,33 +344,28 @@ Future<String?> _showCommentBottomSheet(BuildContext context, String actionText)
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: BaseBottomSheet(
|
||||
title: 'Add Comment for ${capitalizeFirstLetter(actionText)}',
|
||||
title: sheetTitle, // 👈 now showing full sentence as title
|
||||
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);
|
||||
}
|
||||
},
|
||||
child: TextField(
|
||||
controller: commentController,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type your comment here...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
],
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
errorText: errorText,
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (errorText != null) {
|
||||
setModalState(() => errorText = null);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -361,5 +375,6 @@ Future<String?> _showCommentBottomSheet(BuildContext context, String actionText)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
String capitalizeFirstLetter(String text) =>
|
||||
text.isEmpty ? text : text[0].toUpperCase() + text.substring(1);
|
||||
|
@ -217,13 +217,32 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
|
||||
Obx(() {
|
||||
if (!controller.showAddTaskCheckbox.value)
|
||||
return const SizedBox.shrink();
|
||||
return CheckboxListTile(
|
||||
title: MyText.titleSmall("Add new task", fontWeight: 600),
|
||||
value: controller.isAddTaskChecked.value,
|
||||
onChanged: (val) =>
|
||||
controller.isAddTaskChecked.value = val ?? false,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
return 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.white;
|
||||
}),
|
||||
checkColor:
|
||||
MaterialStateProperty.all(Colors.white),
|
||||
),
|
||||
),
|
||||
child: CheckboxListTile(
|
||||
title: MyText.titleSmall("Add new task", fontWeight: 600),
|
||||
value: controller.isAddTaskChecked.value,
|
||||
onChanged: (val) =>
|
||||
controller.isAddTaskChecked.value = val ?? false,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
|
@ -1,16 +1,20 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:url_launcher/url_launcher_string.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/utils/validators.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
||||
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
||||
|
||||
/// Show bottom sheet wrapper
|
||||
Future<T?> showAddExpenseBottomSheet<T>({
|
||||
bool isEdit = false,
|
||||
Map<String, dynamic>? existingExpense,
|
||||
@ -24,6 +28,7 @@ Future<T?> showAddExpenseBottomSheet<T>({
|
||||
);
|
||||
}
|
||||
|
||||
/// Bottom sheet widget
|
||||
class _AddExpenseBottomSheet extends StatefulWidget {
|
||||
final bool isEdit;
|
||||
final Map<String, dynamic>? existingExpense;
|
||||
@ -39,17 +44,21 @@ class _AddExpenseBottomSheet extends StatefulWidget {
|
||||
|
||||
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
final AddExpenseController controller = Get.put(AddExpenseController());
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
final GlobalKey _projectDropdownKey = GlobalKey();
|
||||
final GlobalKey _expenseTypeDropdownKey = GlobalKey();
|
||||
final GlobalKey _paymentModeDropdownKey = GlobalKey();
|
||||
void _showEmployeeList() async {
|
||||
|
||||
/// Show employee list
|
||||
Future<void> _showEmployeeList() async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => ReusableEmployeeSelectorBottomSheet(
|
||||
searchController: controller.employeeSearchController,
|
||||
searchResults: controller.employeeSearchResults,
|
||||
@ -59,16 +68,16 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
),
|
||||
);
|
||||
|
||||
// Optional cleanup
|
||||
controller.employeeSearchController.clear();
|
||||
controller.employeeSearchResults.clear();
|
||||
}
|
||||
|
||||
/// Generic option list
|
||||
Future<void> _showOptionList<T>(
|
||||
List<T> options,
|
||||
String Function(T) getLabel,
|
||||
ValueChanged<T> onSelected,
|
||||
GlobalKey triggerKey, // add this param
|
||||
GlobalKey triggerKey,
|
||||
) async {
|
||||
final RenderBox button =
|
||||
triggerKey.currentContext!.findRenderObject() as RenderBox;
|
||||
@ -87,9 +96,9 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
items: options
|
||||
.map(
|
||||
(option) => PopupMenuItem<T>(
|
||||
value: option,
|
||||
child: Text(getLabel(option)),
|
||||
(opt) => PopupMenuItem<T>(
|
||||
value: opt,
|
||||
child: Text(getLabel(opt)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
@ -100,222 +109,300 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
|
||||
@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(
|
||||
return Obx(
|
||||
() => Form(
|
||||
key: _formKey,
|
||||
child: BaseBottomSheet(
|
||||
title: widget.isEdit ? "Edit Expense" : "Add Expense",
|
||||
isSubmitting: controller.isSubmitting.value,
|
||||
onCancel: Get.back,
|
||||
onSubmit: () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
controller.submitOrUpdateExpense();
|
||||
}
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 🔹 Project
|
||||
_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,
|
||||
),
|
||||
dropdownKey: _projectDropdownKey,
|
||||
),
|
||||
|
||||
MySpacing.height(16),
|
||||
|
||||
// 🔹 Expense Type
|
||||
_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,
|
||||
),
|
||||
|
||||
// 🔹 Persons if required
|
||||
if (controller.selectedExpenseType.value?.noOfPersonsRequired ==
|
||||
true) ...[
|
||||
MySpacing.height(16),
|
||||
_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,
|
||||
),
|
||||
],
|
||||
requiredField: true),
|
||||
MySpacing.height(6),
|
||||
_CustomTextField(
|
||||
controller: controller.noOfPersonsController,
|
||||
hint: "Enter No. of Persons",
|
||||
keyboardType: TextInputType.number,
|
||||
validator: Validators.requiredField,
|
||||
),
|
||||
],
|
||||
|
||||
MySpacing.height(16),
|
||||
|
||||
// 🔹 GST
|
||||
_SectionTitle(
|
||||
icon: Icons.confirmation_number_outlined, title: "GST No."),
|
||||
MySpacing.height(6),
|
||||
_CustomTextField(
|
||||
controller: controller.gstController,
|
||||
hint: "Enter GST No.",
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
return Validators.gstValidator(value);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
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),
|
||||
|
||||
// 🔹 Payment Mode
|
||||
_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.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),
|
||||
|
||||
MySpacing.height(16),
|
||||
|
||||
// 🔹 Paid By
|
||||
_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),
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.my_location),
|
||||
tooltip: "Use Current Location",
|
||||
onPressed: controller.fetchCurrentLocation,
|
||||
const Icon(Icons.arrow_drop_down, size: 22),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
MySpacing.height(16),
|
||||
|
||||
// 🔹 Amount
|
||||
_SectionTitle(
|
||||
icon: Icons.currency_rupee,
|
||||
title: "Amount",
|
||||
requiredField: true),
|
||||
MySpacing.height(6),
|
||||
_CustomTextField(
|
||||
controller: controller.amountController,
|
||||
hint: "Enter Amount",
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) => Validators.isNumeric(v ?? "")
|
||||
? null
|
||||
: "Enter valid amount",
|
||||
),
|
||||
|
||||
MySpacing.height(16),
|
||||
|
||||
// 🔹 Supplier
|
||||
_SectionTitle(
|
||||
icon: Icons.store_mall_directory_outlined,
|
||||
title: "Supplier Name/Transporter Name/Other",
|
||||
requiredField: true,
|
||||
),
|
||||
MySpacing.height(6),
|
||||
_CustomTextField(
|
||||
controller: controller.supplierController,
|
||||
hint: "Enter Supplier Name/Transporter Name or Other",
|
||||
validator: Validators.nameValidator,
|
||||
),
|
||||
|
||||
MySpacing.height(16),
|
||||
|
||||
// 🔹 Transaction ID
|
||||
_SectionTitle(
|
||||
icon: Icons.confirmation_number_outlined,
|
||||
title: "Transaction ID"),
|
||||
MySpacing.height(6),
|
||||
_CustomTextField(
|
||||
controller: controller.transactionIdController,
|
||||
hint: "Enter Transaction ID",
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
return Validators.transactionIdValidator(value);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
MySpacing.height(16),
|
||||
|
||||
// 🔹 Transaction Date
|
||||
_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",
|
||||
validator: Validators.requiredField,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
MySpacing.height(16),
|
||||
|
||||
// 🔹 Location
|
||||
_SectionTitle(
|
||||
icon: Icons.location_on_outlined, title: "Location"),
|
||||
MySpacing.height(6),
|
||||
TextFormField(
|
||||
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),
|
||||
|
||||
// 🔹 Attachments
|
||||
_SectionTitle(
|
||||
icon: Icons.attach_file,
|
||||
title: "Attachments",
|
||||
requiredField: true),
|
||||
MySpacing.height(6),
|
||||
_AttachmentsSection(
|
||||
attachments: controller.attachments,
|
||||
existingAttachments: controller.existingAttachments,
|
||||
onRemoveNew: controller.removeAttachment,
|
||||
onRemoveExisting: (item) async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => ConfirmDialog(
|
||||
title: "Remove Attachment",
|
||||
message:
|
||||
"Are you sure you want to remove this attachment?",
|
||||
confirmText: "Remove",
|
||||
icon: Icons.delete,
|
||||
confirmColor: Colors.redAccent,
|
||||
onConfirm: () async {
|
||||
final index =
|
||||
controller.existingAttachments.indexOf(item);
|
||||
if (index != -1) {
|
||||
controller.existingAttachments[index]['isActive'] =
|
||||
false;
|
||||
controller.existingAttachments.refresh();
|
||||
}
|
||||
showAppSnackbar(
|
||||
title: 'Removed',
|
||||
message: 'Attachment has been removed.',
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onAdd: controller.pickAttachments,
|
||||
),
|
||||
|
||||
MySpacing.height(16),
|
||||
|
||||
// 🔹 Description
|
||||
_SectionTitle(
|
||||
icon: Icons.description_outlined,
|
||||
title: "Description",
|
||||
requiredField: true),
|
||||
MySpacing.height(6),
|
||||
_CustomTextField(
|
||||
controller: controller.descriptionController,
|
||||
hint: "Enter Description",
|
||||
maxLines: 3,
|
||||
validator: Validators.requiredField,
|
||||
),
|
||||
],
|
||||
),
|
||||
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>({
|
||||
@ -324,18 +411,14 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
required bool requiredField,
|
||||
required String value,
|
||||
required VoidCallback onTap,
|
||||
required GlobalKey dropdownKey, // new param
|
||||
required GlobalKey dropdownKey,
|
||||
}) {
|
||||
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,
|
||||
),
|
||||
_DropdownTile(key: dropdownKey, title: value, onTap: onTap),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -386,20 +469,23 @@ class _CustomTextField extends StatelessWidget {
|
||||
final String hint;
|
||||
final int maxLines;
|
||||
final TextInputType keyboardType;
|
||||
final String? Function(String?)? validator; // 👈 for validation
|
||||
|
||||
const _CustomTextField({
|
||||
required this.controller,
|
||||
required this.hint,
|
||||
this.maxLines = 1,
|
||||
this.keyboardType = TextInputType.text,
|
||||
this.validator,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
keyboardType: keyboardType,
|
||||
validator: validator, // 👈 applied
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
@ -503,6 +589,21 @@ class _AttachmentsSection extends StatelessWidget {
|
||||
final activeExistingAttachments =
|
||||
existingAttachments.where((doc) => doc['isActive'] != false).toList();
|
||||
|
||||
// Allowed image extensions for local files
|
||||
final allowedImageExtensions = ['jpg', 'jpeg', 'png'];
|
||||
|
||||
// To show all new attachments in UI but filter only images for dialog
|
||||
final imageFiles = attachments.where((file) {
|
||||
final extension = file.path.split('.').last.toLowerCase();
|
||||
return allowedImageExtensions.contains(extension);
|
||||
}).toList();
|
||||
|
||||
// Filter existing attachments to only images (for dialog)
|
||||
final imageExistingAttachments = activeExistingAttachments
|
||||
.where((d) =>
|
||||
(d['contentType']?.toString().startsWith('image/') ?? false))
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -528,23 +629,22 @@ class _AttachmentsSection extends StatelessWidget {
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
if (isImage) {
|
||||
final imageDocs = activeExistingAttachments
|
||||
.where((d) => (d['contentType']
|
||||
?.toString()
|
||||
.startsWith('image/') ??
|
||||
false))
|
||||
// Open dialog only with image attachments (URLs)
|
||||
final imageSources = imageExistingAttachments
|
||||
.map((e) => e['url'])
|
||||
.toList();
|
||||
final initialIndex =
|
||||
imageDocs.indexWhere((d) => d == doc);
|
||||
final initialIndex = imageExistingAttachments
|
||||
.indexWhere((d) => d == doc);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => ImageViewerDialog(
|
||||
imageSources:
|
||||
imageDocs.map((e) => e['url']).toList(),
|
||||
imageSources: imageSources,
|
||||
initialIndex: initialIndex,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Open non-image attachment externally or show error
|
||||
if (url != null && await canLaunchUrlString(url)) {
|
||||
await launchUrlString(
|
||||
url,
|
||||
@ -607,15 +707,42 @@ class _AttachmentsSection extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// New attachments section
|
||||
// New attachments section: show all files, but only open dialog for images
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
...attachments.map((file) => _AttachmentTile(
|
||||
...attachments.map((file) {
|
||||
final extension = file.path.split('.').last.toLowerCase();
|
||||
final isImage = allowedImageExtensions.contains(extension);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (isImage) {
|
||||
// Show dialog only for image files
|
||||
final initialIndex = imageFiles.indexOf(file);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => ImageViewerDialog(
|
||||
imageSources: imageFiles,
|
||||
initialIndex: initialIndex,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// For non-image, you can show snackbar or do nothing or handle differently
|
||||
showAppSnackbar(
|
||||
title: 'Info',
|
||||
message: 'Preview for this file type is not supported.',
|
||||
type: SnackbarType.info,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: _AttachmentTile(
|
||||
file: file,
|
||||
onRemove: () => onRemoveNew(file),
|
||||
)),
|
||||
),
|
||||
);
|
||||
}),
|
||||
GestureDetector(
|
||||
onTap: onAdd,
|
||||
child: Container(
|
||||
|
@ -11,6 +11,7 @@ import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/view/dashboard/dashboard_chart.dart';
|
||||
import 'package:marco/view/layouts/layout.dart';
|
||||
// import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart'; // ❌ Commented out
|
||||
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
@ -21,7 +22,8 @@ class DashboardScreen extends StatefulWidget {
|
||||
static const String attendanceRoute = "/dashboard/attendance";
|
||||
static const String tasksRoute = "/dashboard/daily-task";
|
||||
static const String dailyTasksRoute = "/dashboard/daily-task-planing";
|
||||
static const String dailyTasksProgressRoute = "/dashboard/daily-task-progress";
|
||||
static const String dailyTasksProgressRoute =
|
||||
"/dashboard/daily-task-progress";
|
||||
static const String directoryMainPageRoute = "/dashboard/directory-main-page";
|
||||
static const String expenseMainPageRoute = "/dashboard/expense-main-page";
|
||||
|
||||
@ -30,7 +32,8 @@ class DashboardScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
final DashboardController dashboardController = Get.put(DashboardController());
|
||||
final DashboardController dashboardController =
|
||||
Get.put(DashboardController());
|
||||
bool hasMpin = true;
|
||||
|
||||
@override
|
||||
@ -52,6 +55,20 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ❌ Commented out FCM Test Button
|
||||
/*
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final fcmService = FirebaseNotificationService();
|
||||
final token = await fcmService.getFcmToken();
|
||||
if (token != null) {
|
||||
await fcmService.sendTestNotification(token);
|
||||
}
|
||||
},
|
||||
child: const Text("Send Test Notification"),
|
||||
),
|
||||
MySpacing.height(10),
|
||||
*/
|
||||
_buildDashboardStats(context),
|
||||
MySpacing.height(24),
|
||||
_buildAttendanceChartSection(),
|
||||
@ -64,12 +81,18 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
/// Dashboard Statistics Section with ProjectController
|
||||
Widget _buildDashboardStats(BuildContext context) {
|
||||
final stats = [
|
||||
_StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success, DashboardScreen.attendanceRoute),
|
||||
_StatItem(LucideIcons.users, "Employees", contentTheme.warning, DashboardScreen.employeesRoute),
|
||||
_StatItem(LucideIcons.logs, "Daily Task Planing", contentTheme.info, DashboardScreen.dailyTasksRoute),
|
||||
_StatItem(LucideIcons.list_todo, "Daily Task Progress", contentTheme.info, DashboardScreen.dailyTasksProgressRoute),
|
||||
_StatItem(LucideIcons.folder, "Directory", contentTheme.info, DashboardScreen.directoryMainPageRoute),
|
||||
_StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info, DashboardScreen.expenseMainPageRoute),
|
||||
_StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success,
|
||||
DashboardScreen.attendanceRoute),
|
||||
_StatItem(LucideIcons.users, "Employees", contentTheme.warning,
|
||||
DashboardScreen.employeesRoute),
|
||||
_StatItem(LucideIcons.logs, "Daily Task Planing", contentTheme.info,
|
||||
DashboardScreen.dailyTasksRoute),
|
||||
_StatItem(LucideIcons.list_todo, "Daily Task Progress", contentTheme.info,
|
||||
DashboardScreen.dailyTasksProgressRoute),
|
||||
_StatItem(LucideIcons.folder, "Directory", contentTheme.info,
|
||||
DashboardScreen.directoryMainPageRoute),
|
||||
_StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info,
|
||||
DashboardScreen.expenseMainPageRoute),
|
||||
];
|
||||
|
||||
return GetBuilder<ProjectController>(
|
||||
@ -89,13 +112,15 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth;
|
||||
final crossAxisCount = (maxWidth / 100).floor().clamp(2, 4);
|
||||
final cardWidth = (maxWidth - (crossAxisCount - 1) * 10) / crossAxisCount;
|
||||
final cardWidth =
|
||||
(maxWidth - (crossAxisCount - 1) * 10) / crossAxisCount;
|
||||
|
||||
return Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: stats
|
||||
.map((stat) => _buildStatCard(stat, cardWidth, isProjectSelected))
|
||||
.map((stat) =>
|
||||
_buildStatCard(stat, cardWidth, isProjectSelected))
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
@ -118,7 +143,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
ignoring: !isProjectSelected,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AttendanceDashboardChart(),
|
||||
child: AttendanceDashboardChart(),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -158,7 +183,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
runSpacing: 10,
|
||||
children: List.generate(
|
||||
4,
|
||||
(index) => _buildStatCardSkeleton(MediaQuery.of(context).size.width / 3),
|
||||
(index) =>
|
||||
_buildStatCardSkeleton(MediaQuery.of(context).size.width / 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -229,7 +255,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
if (!isEnabled) {
|
||||
Get.defaultDialog(
|
||||
title: "No Project Selected",
|
||||
middleText: "You need to select a project before accessing this section.",
|
||||
middleText:
|
||||
"You need to select a project before accessing this section.",
|
||||
confirm: ElevatedButton(
|
||||
onPressed: () => Get.back(),
|
||||
child: const Text("OK"),
|
||||
|
@ -451,7 +451,7 @@ class _InvoiceHeader extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final dateString = DateTimeUtils.convertUtcToLocal(
|
||||
expense.transactionDate.toString(),
|
||||
format: 'dd-MM-yyyy');
|
||||
format: 'dd MMM yyyy');
|
||||
final statusColor = getExpenseStatusColor(expense.status.name,
|
||||
colorCode: expense.status.color);
|
||||
return Column(
|
||||
@ -514,10 +514,10 @@ class _InvoiceDetailsTable extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final transactionDate = DateTimeUtils.convertUtcToLocal(
|
||||
expense.transactionDate.toString(),
|
||||
format: 'dd-MM-yyyy hh:mm a');
|
||||
format: 'dd MMM yyyy');
|
||||
final createdAt = DateTimeUtils.convertUtcToLocal(
|
||||
expense.createdAt.toString(),
|
||||
format: 'dd-MM-yyyy hh:mm a');
|
||||
format: 'dd MMM yyyy');
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
@ -72,7 +72,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
|
||||
e.transactionDate.year == now.year)
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
|
||||
set(BINARY_NAME "marco")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "com.marco.aiotstage")
|
||||
set(APPLICATION_ID "com.marco.aiot")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
|
@ -385,7 +385,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco";
|
||||
@ -399,7 +399,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco";
|
||||
@ -413,7 +413,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco";
|
||||
|
@ -8,7 +8,7 @@
|
||||
PRODUCT_NAME = marco
|
||||
|
||||
// The application's bundle identifier
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot
|
||||
|
||||
// The copyright displayed in application information
|
||||
PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved.
|
||||
|
26
pubspec.yaml
26
pubspec.yaml
@ -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
|
||||
# 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.
|
||||
version: 1.0.0+6
|
||||
version: 1.0.0+7
|
||||
|
||||
environment:
|
||||
sdk: ^3.5.3
|
||||
@ -50,17 +50,17 @@ dependencies:
|
||||
loading_animation_widget: ^1.3.0
|
||||
flutter_quill: ^10.8.5
|
||||
intl: ^0.19.0
|
||||
syncfusion_flutter_core: ^28.1.33
|
||||
syncfusion_flutter_sliders: ^28.1.33
|
||||
file_picker: ^9.2.3
|
||||
syncfusion_flutter_core: ^29.1.40
|
||||
syncfusion_flutter_sliders: ^29.1.40
|
||||
file_picker: ^10.3.2
|
||||
timelines_plus: ^1.0.4
|
||||
syncfusion_flutter_charts: ^28.1.33
|
||||
syncfusion_flutter_charts: ^29.1.40
|
||||
appflowy_board: ^0.1.2
|
||||
syncfusion_flutter_calendar: ^28.2.6
|
||||
syncfusion_flutter_maps: ^28.1.33
|
||||
syncfusion_flutter_calendar: ^29.1.40
|
||||
syncfusion_flutter_maps: ^29.1.40
|
||||
http: ^1.2.2
|
||||
geolocator: ^9.0.1
|
||||
permission_handler: ^11.3.0
|
||||
geolocator: ^14.0.2
|
||||
permission_handler: ^12.0.1
|
||||
image: ^4.0.17
|
||||
image_picker: ^1.0.7
|
||||
logger: ^2.0.2
|
||||
@ -79,6 +79,12 @@ dependencies:
|
||||
quill_delta: ^3.0.0-nullsafety.2
|
||||
connectivity_plus: ^6.1.4
|
||||
geocoding: ^4.0.0
|
||||
firebase_core: ^4.0.0
|
||||
firebase_messaging: ^16.0.0
|
||||
googleapis_auth: ^2.0.0
|
||||
device_info_plus: ^11.3.0
|
||||
flutter_local_notifications: 19.4.0
|
||||
|
||||
timeline_tile: ^2.0.0
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@ -112,6 +118,8 @@ flutter:
|
||||
- assets/logo/
|
||||
- assets/logo/loading_logo.png
|
||||
- assets/social/
|
||||
- assets/service-account.json
|
||||
- assets/service-account.json
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
@ -13,7 +13,7 @@ import 'package:marco/main.dart';
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
await tester.pumpWidget(const MainWrapper());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
|
Loading…
x
Reference in New Issue
Block a user