Compare commits

..

1 Commits

Author SHA1 Message Date
Vaibhav Surve
5cc4bfd9f6 added firebase config 2025-07-16 12:45:34 +05:30
219 changed files with 11737 additions and 30561 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,13 +0,0 @@
{
"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"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/employees/employee_model.dart'; import 'package:marco/model/employee_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/directory/directory_controller.dart'; import 'package:marco/controller/directory/directory_controller.dart';

View File

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

View File

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

View File

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

View File

@ -1,181 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/document/document_filter_model.dart';
import 'package:marco/model/document/documents_list_model.dart';
class DocumentController extends GetxController {
// ------------------ Observables ---------------------
var isLoading = false.obs;
var documents = <DocumentItem>[].obs;
var filters = Rxn<DocumentFiltersData>();
// Selected filters (multi-select support)
var selectedUploadedBy = <String>[].obs;
var selectedCategory = <String>[].obs;
var selectedType = <String>[].obs;
var selectedTag = <String>[].obs;
// Pagination state
var pageNumber = 1.obs;
final int pageSize = 20;
var hasMore = true.obs;
// Error message
var errorMessage = "".obs;
// NEW: show inactive toggle
var showInactive = false.obs;
// NEW: search
var searchQuery = ''.obs;
var searchController = TextEditingController();
// New filter fields
var isUploadedAt = true.obs;
var isVerified = RxnBool();
var startDate = Rxn<String>();
var endDate = Rxn<String>();
// ------------------ API Calls -----------------------
/// Fetch Document Filters for an Entity
Future<void> fetchFilters(String entityTypeId) async {
try {
isLoading.value = true;
final response = await ApiService.getDocumentFilters(entityTypeId);
if (response != null && response.success) {
filters.value = response.data;
} else {
errorMessage.value = response?.message ?? "Failed to fetch filters";
}
} catch (e) {
errorMessage.value = "Error fetching filters: $e";
} finally {
isLoading.value = false;
}
}
/// Toggle document active/inactive state
Future<bool> toggleDocumentActive(
String id, {
required bool isActive,
required String entityTypeId,
required String entityId,
}) async {
try {
isLoading.value = true;
final success =
await ApiService.deleteDocumentApi(id: id, isActive: isActive);
if (success) {
// 🔥 Always fetch fresh list after toggle
await fetchDocuments(
entityTypeId: entityTypeId,
entityId: entityId,
reset: true,
);
return true;
} else {
errorMessage.value = "Failed to update document state";
return false;
}
} catch (e) {
errorMessage.value = "Error updating document: $e";
return false;
} finally {
isLoading.value = false;
}
}
/// Permanently delete a document (or deactivate depending on API)
Future<bool> deleteDocument(String id, {bool isActive = false}) async {
try {
isLoading.value = true;
final success =
await ApiService.deleteDocumentApi(id: id, isActive: isActive);
if (success) {
// remove from local list immediately for better UX
documents.removeWhere((doc) => doc.id == id);
return true;
} else {
errorMessage.value = "Failed to delete document";
return false;
}
} catch (e) {
errorMessage.value = "Error deleting document: $e";
return false;
} finally {
isLoading.value = false;
}
}
/// Fetch Documents for an entity
Future<void> fetchDocuments({
required String entityTypeId,
required String entityId,
String? filter,
String? searchString,
bool reset = false,
}) async {
try {
if (reset) {
pageNumber.value = 1;
documents.clear();
hasMore.value = true;
}
if (!hasMore.value) return;
isLoading.value = true;
final response = await ApiService.getDocumentListApi(
entityTypeId: entityTypeId,
entityId: entityId,
filter: filter ?? "",
searchString: searchString ?? searchQuery.value,
pageNumber: pageNumber.value,
pageSize: pageSize,
isActive: !showInactive.value,
);
if (response != null && response.success) {
if (response.data.data.isNotEmpty) {
documents.addAll(response.data.data);
pageNumber.value++;
} else {
hasMore.value = false;
}
} else {
errorMessage.value = response?.message ?? "Failed to fetch documents";
}
} catch (e) {
errorMessage.value = "Error fetching documents: $e";
} finally {
isLoading.value = false;
}
}
// ------------------ Helpers -----------------------
/// Clear selected filters
void clearFilters() {
selectedUploadedBy.clear();
selectedCategory.clear();
selectedType.clear();
selectedTag.clear();
isUploadedAt.value = true;
isVerified.value = null;
startDate.value = null;
endDate.value = null;
}
/// Check if any filters are active (for red dot indicator)
bool hasActiveFilters() {
return selectedUploadedBy.isNotEmpty ||
selectedCategory.isNotEmpty ||
selectedType.isNotEmpty ||
selectedTag.isNotEmpty;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/dailyTaskPlanning/master_work_category_model.dart'; import 'package:marco/model/dailyTaskPlaning/master_work_category_model.dart';
class AddTaskController extends GetxController { class AddTaskController extends GetxController {
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs; RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;

View File

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

View File

@ -7,12 +7,12 @@ import 'package:image_picker/image_picker.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/controller/my_controller.dart'; import 'package:marco/controller/my_controller.dart';
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart'; import 'package:marco/controller/task_planing/daily_task_planing_controller.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_image_compressor.dart'; import 'package:marco/helpers/widgets/my_image_compressor.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/dailyTaskPlanning/work_status_model.dart'; import 'package:marco/model/dailyTaskPlaning/work_status_model.dart';
enum ApiStatus { idle, loading, success, failure } enum ApiStatus { idle, loading, success, failure }
@ -34,7 +34,7 @@ class ReportTaskActionController extends MyController {
final RxString selectedWorkStatusName = ''.obs; final RxString selectedWorkStatusName = ''.obs;
final MyFormValidator basicValidator = MyFormValidator(); final MyFormValidator basicValidator = MyFormValidator();
final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController()); final DailyTaskPlaningController taskController = Get.put(DailyTaskPlaningController());
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
final assignedDateController = TextEditingController(); final assignedDateController = TextEditingController();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,106 +0,0 @@
import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/tenant_service.dart';
import 'package:marco/model/tenant/tenant_list_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/controller/permission_controller.dart';
class TenantSwitchController extends GetxController {
final TenantService _tenantService = TenantService();
final tenants = <Tenant>[].obs;
final isLoading = false.obs;
final selectedTenantId = RxnString();
@override
void onInit() {
super.onInit();
loadTenants();
}
/// Load all tenants for switching (does not auto-select)
Future<void> loadTenants() async {
isLoading.value = true;
try {
final data = await _tenantService.getTenants();
if (data == null || data.isEmpty) {
tenants.clear();
logSafe("⚠️ No tenants available for switching.", level: LogLevel.warning);
return;
}
tenants.value = data.map((e) => Tenant.fromJson(e)).toList();
// Keep current tenant as selected
selectedTenantId.value = TenantService.currentTenant?.id;
} catch (e, st) {
logSafe("❌ Exception in loadTenants", level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar(
title: "Error",
message: "Failed to load organizations for switching.",
type: SnackbarType.error,
);
} finally {
isLoading.value = false;
}
}
/// Switch to a different tenant and navigate fully
Future<void> switchTenant(String tenantId) async {
if (TenantService.currentTenant?.id == tenantId) return;
isLoading.value = true;
try {
final success = await _tenantService.selectTenant(tenantId);
if (!success) {
logSafe("❌ Tenant switch failed: $tenantId", level: LogLevel.warning);
showAppSnackbar(
title: "Error",
message: "Unable to switch organization. Try again.",
type: SnackbarType.error,
);
return;
}
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
TenantService.setSelectedTenant(selectedTenant);
selectedTenantId.value = tenantId;
// Persist recent tenant
await LocalStorage.setRecentTenantId(tenantId);
logSafe("✅ Tenant switched successfully: $tenantId");
// 🔹 Load permissions after tenant switch (null-safe)
final token = await LocalStorage.getJwtToken();
if (token != null && token.isNotEmpty) {
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
logSafe("✅ PermissionController injected after tenant switch.");
}
await Get.find<PermissionController>().loadData(token);
} else {
logSafe("⚠️ JWT token is null. Cannot load permissions.", level: LogLevel.warning);
}
// FULL NAVIGATION: reload app/dashboard
Get.offAllNamed('/dashboard');
showAppSnackbar(
title: "Success",
message: "Switched to organization: ${selectedTenant.name}",
type: SnackbarType.success,
);
} catch (e, st) {
logSafe("❌ Exception in switchTenant", level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar(
title: "Error",
message: "An unexpected error occurred while switching organization.",
type: SnackbarType.error,
);
} finally {
isLoading.value = false;
}
}
}

View File

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

View File

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

View File

@ -1,38 +1,27 @@
class ApiEndpoints { class ApiEndpoints {
static const String baseUrl = "https://stageapi.marcoaiot.com/api"; static const String baseUrl = "https://stageapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
// Dashboard Module API Endpoints // Dashboard Screen API Endpoints
static const String getDashboardAttendanceOverview = static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview";
"/dashboard/attendance-overview";
static const String getDashboardProjectProgress = "/dashboard/progression";
static const String getDashboardTasks = "/dashboard/tasks";
static const String getDashboardTeams = "/dashboard/teams";
static const String getDashboardProjects = "/dashboard/projects";
// Attendance Module API Endpoints // Attendance Screen API Endpoints
static const String getProjects = "/project/list"; static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic"; static const String getGlobalProjects = "/project/list/basic";
static const String getTodaysAttendance = "/attendance/project/team"; static const String getEmployeesByProject = "/attendance/project/team";
static const String getAttendanceLogs = "/attendance/project/log"; static const String getAttendanceLogs = "/attendance/project/log";
static const String getAttendanceLogView = "/attendance/log/attendance"; static const String getAttendanceLogView = "/attendance/log/attendance";
static const String getRegularizationLogs = "/attendance/regularize"; static const String getRegularizationLogs = "/attendance/regularize";
static const String uploadAttendanceImage = "/attendance/record-image"; static const String uploadAttendanceImage = "/attendance/record-image";
// Employee Screen API Endpoints // Employee Screen API Endpoints
static const String getAllEmployeesByProject = "/employee/list"; static const String getAllEmployeesByProject = "/Project/employees/get";
static const String getAllEmployeesByOrganization = "/project/get/task/team";
static const String getAllEmployees = "/employee/list"; static const String getAllEmployees = "/employee/list";
static const String getEmployeesWithoutPermission = "/employee/basic";
static const String getRoles = "/roles/jobrole"; static const String getRoles = "/roles/jobrole";
static const String createEmployee = "/employee/app/manage"; static const String createEmployee = "/employee/manage";
static const String getEmployeeInfo = "/employee/profile/get"; static const String getEmployeeInfo = "/employee/profile/get";
static const String assignEmployee = "/employee/profile/get";
static const String getAssignedProjects = "/project/assigned-projects";
static const String assignProjects = "/project/assign-projects";
// Daily Task Module API Endpoints // Daily Task Screen API Endpoints
static const String getDailyTask = "/task/list"; static const String getDailyTask = "/task/list";
static const String reportTask = "/task/report"; static const String reportTask = "/task/report";
static const String commentTask = "/task/comment"; static const String commentTask = "/task/comment";
@ -42,62 +31,19 @@ class ApiEndpoints {
static const String approveReportAction = "/task/approve"; static const String approveReportAction = "/task/approve";
static const String assignTask = "/project/task"; static const String assignTask = "/project/task";
static const String getmasterWorkCategories = "/Master/work-categories"; static const String getmasterWorkCategories = "/Master/work-categories";
static const String getDailyTaskProjectProgressFilter = "/task/filter";
////// Directory Module API Endpoints /////// ////// Directory Screen API Endpoints
static const String getDirectoryContacts = "/directory"; static const String getDirectoryContacts = "/directory";
static const String getDirectoryBucketList = "/directory/buckets"; static const String getDirectoryBucketList = "/directory/buckets";
static const String getDirectoryContactDetail = "/directory/notes"; static const String getDirectoryContactDetail = "/directory/notes";
static const String getDirectoryContactCategory = static const String getDirectoryContactCategory = "/master/contact-categories";
"/master/contact-categories";
static const String getDirectoryContactTags = "/master/contact-tags"; static const String getDirectoryContactTags = "/master/contact-tags";
static const String getDirectoryOrganization = "/directory/organization"; static const String getDirectoryOrganization = "/directory/organization";
static const String createContact = "/directory"; static const String createContact = "/directory";
static const String updateContact = "/directory"; static const String updateContact = "/directory";
static const String deleteContact = "/directory";
static const String restoreContact = "/directory/note";
static const String getDirectoryNotes = "/directory/notes"; static const String getDirectoryNotes = "/directory/notes";
static const String updateDirectoryNotes = "/directory/note"; static const String updateDirectoryNotes = "/directory/note";
static const String createBucket = "/directory/bucket"; static const String createBucket = "/directory/bucket";
static const String updateBucket = "/directory/bucket"; static const String updateBucket = "/directory/bucket";
static const String assignBucket = "/directory/assign-bucket"; static const String assignBucket = "/directory/assign-bucket";
////// Expense Module API Endpoints
static const String getExpenseCategories = "/expense/categories";
static const String getExpenseList = "/expense/list";
static const String getExpenseDetails = "/expense/details";
static const String createExpense = "/expense/create";
static const String editExpense = "/Expense/edit";
static const String getMasterPaymentModes = "/master/payment-modes";
static const String getMasterExpenseStatus = "/master/expenses-status";
static const String getMasterExpenseTypes = "/master/expenses-types";
static const String updateExpenseStatus = "/expense/action";
static const String deleteExpense = "/expense/delete";
////// Dynamic Menu Module API Endpoints
static const String getDynamicMenu = "/appmenu/get/menu-mobile";
///// Document Module API Endpoints
static const String getMasterDocumentCategories =
"/master/document-category/list";
static const String getMasterDocumentTags = "/document/get/tags";
static const String getDocumentList = "/document/list";
static const String getDocumentDetails = "/document/get/details";
static const String uploadDocument = "/document/upload";
static const String deleteDocument = "/document/delete";
static const String getDocumentFilter = "/document/get/filter";
static const String getDocumentTypesByCategory = "/master/document-type/list";
static const String getDocumentVersion = "/document/get/version";
static const String getDocumentVersions = "/document/list/versions";
static const String editDocument = "/document/edit";
static const String verifyDocument = "/document/verify";
/// Logs Module API Endpoints
static const String uploadLogs = "/log";
static const String getAssignedOrganizations =
"/project/get/assigned/organization";
static const getAllOrganizations = "/organization/list";
static const String getAssignedServices = "/Project/get/assigned/services";
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,391 +0,0 @@
import 'package:get/get.dart';
import 'package:logger/logger.dart';
import 'package:marco/controller/attendance/attendance_screen_controller.dart';
import 'package:marco/controller/task_planning/daily_task_controller.dart';
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart';
import 'package:marco/controller/expense/expense_screen_controller.dart';
import 'package:marco/controller/expense/expense_detail_controller.dart';
import 'package:marco/controller/directory/directory_controller.dart';
import 'package:marco/controller/directory/notes_controller.dart';
import 'package:marco/controller/document/user_document_controller.dart';
import 'package:marco/controller/document/document_details_controller.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
/// Handles incoming FCM notification actions and updates UI/controllers.
class NotificationActionHandler {
static final Logger _logger = Logger();
/// Main entry point call this for any notification `data` map.
static void handle(Map<String, dynamic> data) {
_logger.i('📲 Handling notification action: $data');
if (data.isEmpty) {
_logger.w('⚠️ Empty notification data received.');
return;
}
final type = data['type'];
final action = data['Action'];
final keyword = data['Keyword'];
if (type != null) {
_handleByType(type, data);
} else if (keyword != null) {
_handleByKeyword(keyword, action, data);
} else {
_logger.w('⚠️ Unhandled notification: $data');
}
}
/// Handle notification if identified by `type`
static void _handleByType(String type, Map<String, dynamic> data) {
switch (type) {
case 'expense_updated':
_handleExpenseUpdated(data);
break;
case 'attendance_updated':
_handleAttendanceUpdated(data);
_handleDashboardUpdate(data); // refresh dashboard attendance
break;
case 'dashboard_update':
_handleDashboardUpdate(data); // full dashboard refresh
break;
default:
_logger.w('⚠️ Unknown notification type: $type');
}
}
/// Handle notification if identified by `keyword`
static void _handleByKeyword(
String keyword, String? action, Map<String, dynamic> data) {
switch (keyword) {
/// 🔹 Attendance
case 'Attendance':
if (_isAttendanceAction(action)) {
_handleAttendanceUpdated(data);
_handleDashboardUpdate(data);
}
break;
case 'Team_Modified':
// Call method to handle team modifications and dashboard update
_handleDashboardUpdate(data);
break;
/// 🔹 Tasks
case 'Report_Task':
_handleTaskUpdated(data, isComment: false);
_handleDashboardUpdate(data);
break;
case 'Task_Comment':
_handleTaskUpdated(data, isComment: true);
_handleDashboardUpdate(data);
break;
case 'Task_Modified':
case 'WorkArea_Modified':
case 'Floor_Modified':
case 'Building_Modified':
_handleTaskPlanningUpdated(data);
_handleDashboardUpdate(data);
break;
/// 🔹 Expenses
case 'Expenses_Modified':
_handleExpenseUpdated(data);
_handleDashboardUpdate(data);
break;
/// 🔹 Documents
case 'Employee_Document_Modified':
case 'Project_Document_Modified':
_handleDocumentModified(data);
break;
/// 🔹 Directory / Contacts
case 'Contact_Modified':
_handleContactModified(data);
break;
case 'Contact_Note_Modified':
_handleContactNoteModified(data);
break;
case 'Bucket_Modified':
_handleBucketModified(data);
break;
case 'Bucket_Assigned':
_handleBucketAssigned(data);
break;
default:
_logger.w('⚠️ Unhandled notification keyword: $keyword');
}
}
/// ---------------------- HANDLERS ----------------------
static void _handleTaskPlanningUpdated(Map<String, dynamic> data) {
final projectId = data['ProjectId'];
if (projectId == null) {
_logger.w("⚠️ TaskPlanning update received without ProjectId: $data");
return;
}
_safeControllerUpdate<DailyTaskPlanningController>(
onFound: (controller) {
controller.fetchTaskData(projectId);
},
notFoundMessage:
'⚠️ DailyTaskPlanningController not found, cannot refresh.',
successMessage:
'✅ DailyTaskPlanningController refreshed from notification.',
);
}
static bool _isAttendanceAction(String? action) {
const validActions = {
'CHECK_IN',
'CHECK_OUT',
'REQUEST_REGULARIZE',
'REQUEST_DELETE',
'REGULARIZE',
'REGULARIZE_REJECT'
};
return validActions.contains(action);
}
static void _handleExpenseUpdated(Map<String, dynamic> data) {
final expenseId = data['ExpenseId'];
if (expenseId == null) {
_logger.w("⚠️ Expense update received without ExpenseId: $data");
return;
}
// Update Expense List
_safeControllerUpdate<ExpenseController>(
onFound: (controller) async {
await controller.fetchExpenses();
},
notFoundMessage: '⚠️ ExpenseController not found, cannot refresh list.',
successMessage:
'✅ ExpenseController refreshed from expense notification.',
);
// Update Expense Detail (if open and matches this expenseId)
_safeControllerUpdate<ExpenseDetailController>(
onFound: (controller) async {
if (controller.expense.value?.id == expenseId) {
await controller.fetchExpenseDetails();
_logger
.i("✅ ExpenseDetailController refreshed for Expense $expenseId");
}
},
notFoundMessage: ' ExpenseDetailController not active, skipping.',
successMessage: '✅ ExpenseDetailController checked for refresh.',
);
}
static void _handleAttendanceUpdated(Map<String, dynamic> data) {
_safeControllerUpdate<AttendanceController>(
onFound: (controller) => controller.refreshDataFromNotification(
projectId: data['ProjectId'],
),
notFoundMessage: '⚠️ AttendanceController not found, cannot update.',
successMessage: '✅ AttendanceController refreshed from notification.',
);
}
static void _handleTaskUpdated(Map<String, dynamic> data,
{required bool isComment}) {
_safeControllerUpdate<DailyTaskController>(
onFound: (controller) => controller.refreshTasksFromNotification(
projectId: data['ProjectId'],
taskAllocationId: data['TaskAllocationId'],
),
notFoundMessage: '⚠️ DailyTaskController not found, cannot update.',
successMessage: '✅ DailyTaskController refreshed from notification.',
);
}
/// ---------------------- DOCUMENT HANDLER ----------------------
static void _handleDocumentModified(Map<String, dynamic> data) {
String entityTypeId;
String entityId;
String? documentId = data['DocumentId'];
// Determine entity type and ID
if (data['Keyword'] == 'Employee_Document_Modified') {
entityTypeId = Permissions.employeeEntity;
entityId = data['EmployeeId'] ?? '';
} else if (data['Keyword'] == 'Project_Document_Modified') {
entityTypeId = Permissions.projectEntity;
entityId = data['ProjectId'] ?? '';
} else {
_logger.w("⚠️ Document update received with unknown keyword: $data");
return;
}
if (entityId.isEmpty) {
_logger.w("⚠️ Document update missing entityId: $data");
return;
}
_logger.i(
"🔔 Document notification received: keyword=${data['Keyword']}, entityTypeId=$entityTypeId, entityId=$entityId, documentId=$documentId");
// Refresh Document List
if (Get.isRegistered<DocumentController>()) {
_safeControllerUpdate<DocumentController>(
onFound: (controller) async {
await controller.fetchDocuments(
entityTypeId: entityTypeId,
entityId: entityId,
reset: true,
);
},
notFoundMessage:
'⚠️ DocumentController not found, cannot refresh list.',
successMessage: '✅ DocumentController refreshed from notification.',
);
} else {
_logger.w('⚠️ DocumentController not registered, skipping list refresh.');
}
// Refresh Document Details (if open)
if (documentId != null && Get.isRegistered<DocumentDetailsController>()) {
_safeControllerUpdate<DocumentDetailsController>(
onFound: (controller) async {
// Refresh details regardless of current document
await controller.fetchDocumentDetails(documentId);
_logger.i(
"✅ DocumentDetailsController refreshed for Document $documentId");
},
notFoundMessage:
' DocumentDetailsController not active, skipping details refresh.',
successMessage: '✅ DocumentDetailsController checked for refresh.',
);
} else if (documentId != null) {
_logger.w(
'⚠️ DocumentDetailsController not registered, cannot refresh document details.');
}
}
/// ---------------------- DIRECTORY HANDLERS ----------------------
static void _handleContactModified(Map<String, dynamic> data) {
final contactId = data['ContactId'];
// Always refresh the contact list
_safeControllerUpdate<DirectoryController>(
onFound: (controller) {
controller.fetchContacts();
// If a specific contact is provided, refresh its notes as well
if (contactId != null) {
controller.fetchCommentsForContact(contactId);
}
},
notFoundMessage:
'⚠️ DirectoryController not found, cannot refresh contacts.',
successMessage:
'✅ Directory contacts (and notes if applicable) refreshed from notification.',
);
// Refresh notes globally as well
_safeControllerUpdate<NotesController>(
onFound: (controller) => controller.fetchNotes(),
notFoundMessage: '⚠️ NotesController not found, cannot refresh notes.',
successMessage: '✅ Notes refreshed from notification.',
);
}
static void _handleContactNoteModified(Map<String, dynamic> data) {
// Refresh both contacts and notes when a note is modified
_handleContactModified(data);
}
static void _handleBucketModified(Map<String, dynamic> data) {
_safeControllerUpdate<DirectoryController>(
onFound: (controller) => controller.fetchBuckets(),
notFoundMessage: '⚠️ DirectoryController not found, cannot refresh.',
successMessage: '✅ Buckets refreshed from notification.',
);
}
static void _handleBucketAssigned(Map<String, dynamic> data) {
_safeControllerUpdate<DirectoryController>(
onFound: (controller) => controller.fetchBuckets(),
notFoundMessage: '⚠️ DirectoryController not found, cannot refresh.',
successMessage: '✅ Bucket assignments refreshed from notification.',
);
}
/// ---------------------- DASHBOARD HANDLER ----------------------
static void _handleDashboardUpdate(Map<String, dynamic> data) {
_safeControllerUpdate<DashboardController>(
onFound: (controller) async {
final type = data['type'] ?? '';
switch (type) {
case 'attendance_updated':
await controller.fetchRoleWiseAttendance();
break;
case 'task_updated':
await controller.fetchDashboardTasks(
projectId: controller.projectController.selectedProjectId.value,
);
break;
case 'project_progress_update':
await controller.fetchProjectProgress();
break;
case 'Employee_Suspend':
final currentProjectId =
controller.projectController.selectedProjectId.value;
final projectIdsString = data['ProjectIds'] ?? '';
// Convert comma-separated string to List<String>
final notificationProjectIds =
projectIdsString.split(',').map((e) => e.trim()).toList();
// Refresh only if current project ID is in the list
if (notificationProjectIds.contains(currentProjectId)) {
await controller.fetchDashboardTeams(projectId: currentProjectId);
}
break;
case 'Team_Modified':
final projectId = data['ProjectId'] ??
controller.projectController.selectedProjectId.value;
await controller.fetchDashboardTeams(projectId: projectId);
break;
case 'full_dashboard_refresh':
default:
await controller.refreshDashboard();
}
},
notFoundMessage: '⚠️ DashboardController not found, cannot refresh.',
successMessage: '✅ DashboardController refreshed from notification.',
);
}
/// ---------------------- UTILITY ----------------------
static void _safeControllerUpdate<T>({
required void Function(T controller) onFound,
required String notFoundMessage,
required String successMessage,
}) {
try {
final controller = Get.find<T>();
onFound(controller);
_logger.i(successMessage);
} catch (e) {
_logger.w(notFoundMessage);
}
}
}

View File

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

View File

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

View File

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

View File

@ -230,7 +230,7 @@ class AppStyle {
containerRadius: AppStyle.containerRadius.medium, containerRadius: AppStyle.containerRadius.medium,
cardRadius: AppStyle.cardRadius.medium, cardRadius: AppStyle.cardRadius.medium,
buttonRadius: AppStyle.buttonRadius.medium, buttonRadius: AppStyle.buttonRadius.medium,
defaultBreadCrumbItem: MyBreadcrumbItem(name: 'Marco', route: '/client/dashboard'), defaultBreadCrumbItem: MyBreadcrumbItem(name: 'Marco', route: '/client/home'),
)); ));
bool isMobile = true; bool isMobile = true;
try { try {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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