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"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
id("com.google.gms.google-services")
}
// Load keystore properties from key.properties file
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
// Define the namespace for your Android application
namespace = "com.marco.aiot"
// Set the compile SDK version based on Flutter's configuration
namespace = "com.example.marco"
compileSdk = flutter.compileSdkVersion
// Set the NDK version based on Flutter's configuration
ndkVersion = flutter.ndkVersion
// Configure Java compatibility options
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
// Enable core library desugaring for Java 8+ APIs
coreLibraryDesugaringEnabled true
}
// Configure Kotlin options for JVM target
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
// Default configuration for your application
defaultConfig {
// Specify your unique Application ID. This identifies your app on Google Play.
applicationId = "com.marco.aiot"
// Set minimum and target SDK versions based on Flutter's configuration
minSdk = 23
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.marcostage"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
// Set version code and name based on Flutter's configuration (from pubspec.yaml)
versionCode = flutter.versionCode
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 {
release {
// Apply the 'release' signing configuration defined above to the release build
signingConfig signingConfigs.release
// Enable code minification to reduce app size
minifyEnabled true
// Enable resource shrinking to remove unused resources
shrinkResources true
// Other release specific configurations can be added here, e.g., ProGuard rules
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.debug
}
}
}
// Configure Flutter specific settings, pointing to the root of your Flutter project
flutter {
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.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
</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">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<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.POST_NOTIFICATIONS"/>
<application
android:label="Marco"
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
<application android:label="Marco_Stage" 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
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
<meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme"/>
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="high_importance_channel"/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="high_importance_channel"/>
</application>
<!-- Required to query activities that can process text, see:
<meta-data android:name="flutterEmbedding" android:value="2"/>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
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. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

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

View File

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

View File

@ -18,9 +18,10 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.6.0" apply false
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
id "com.android.application" version "8.2.1" 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
}
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)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot;
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@ -384,7 +384,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -401,7 +401,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -416,7 +416,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -547,7 +547,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot;
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -569,7 +569,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot;
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
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/services/storage/local_storage.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 {
final MyFormValidator basicValidator = MyFormValidator();
@ -14,7 +14,6 @@ class LoginController extends MyController {
final RxBool isLoading = false.obs;
final RxBool showPassword = false.obs;
final RxBool isChecked = false.obs;
final RxBool showSplash = false.obs;
@override
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 {
if (!basicValidator.validateForm()) return;
showSplash.value = true;
isLoading.value = true;
try {
final loginData = basicValidator.getData();
logSafe("Attempting login for user: ${loginData['username']}");
logSafe("Attempting login for user: ${loginData['username']}", );
final errors = await AuthService.loginUser(loginData);
if (errors != null) {
logSafe("Login failed for user: ${loginData['username']} with errors: $errors", level: LogLevel.warning, );
showAppSnackbar(
title: "Login Failed",
message: "Username or password is incorrect",
type: SnackbarType.error,
);
basicValidator.addErrors(errors);
basicValidator.validateForm();
basicValidator.clearErrors();
} else {
await _handleRememberMe();
enableRemoteLogging();
logSafe("Login successful for user: ${loginData['username']}");
Get.offNamed('/select-tenant');
logSafe("Login successful for user: ${loginData['username']}", );
Get.toNamed('/home');
}
} catch (e, stacktrace) {
logSafe("Exception during login", level: LogLevel.error, error: e, stackTrace: stacktrace);
showAppSnackbar(
title: "Login Error",
message: "An unexpected error occurred",
type: SnackbarType.error,
);
logSafe("Exception during login",
level: LogLevel.error, error: e, stackTrace: stacktrace);
} finally {
showSplash.value = false;
isLoading.value = false;
}
}
Future<void> _handleRememberMe() async {
if (isChecked.value) {
await LocalStorage.setToken(
'username', basicValidator.getController('username')!.text);
await LocalStorage.setToken(
'password', basicValidator.getController('password')!.text);
await LocalStorage.setToken('username', basicValidator.getController('username')!.text);
await LocalStorage.setToken('password', basicValidator.getController('password')!.text);
await LocalStorage.setBool('remember_me', true);
} else {
await LocalStorage.removeToken('username');
@ -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/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/view/dashboard/dashboard_screen.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/project_controller.dart';
class MPINController extends GetxController {
final MyFormValidator basicValidator = MyFormValidator();
final isNewUser = false.obs;
final isChangeMpin = false.obs;
final RxBool isLoading = false.obs;
final formKey = GlobalKey<FormState>();
// Updated to 4-digit MPIN
final digitControllers = List.generate(4, (_) => TextEditingController());
final focusNodes = List.generate(4, (_) => FocusNode());
final retypeControllers = List.generate(4, (_) => TextEditingController());
final retypeFocusNodes = List.generate(4, (_) => FocusNode());
final digitControllers = List.generate(6, (_) => TextEditingController());
final focusNodes = List.generate(6, (_) => FocusNode());
final retypeControllers = List.generate(6, (_) => TextEditingController());
final retypeFocusNodes = List.generate(6, (_) => FocusNode());
final RxInt failedAttempts = 0.obs;
@override
@ -33,28 +28,16 @@ class MPINController extends GetxController {
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}) {
logSafe(
"onDigitChanged -> index: $index, value: $value, isRetype: $isRetype");
logSafe("onDigitChanged -> index: $index, value: $value, isRetype: $isRetype", );
final nodes = isRetype ? retypeFocusNodes : focusNodes;
if (value.isNotEmpty && index < 3) {
if (value.isNotEmpty && index < 5) {
nodes[index + 1].requestFocus();
} else if (value.isEmpty && index > 0) {
nodes[index - 1].requestFocus();
}
}
/// Submit MPIN for verification or generation
Future<void> onSubmitMPIN() async {
logSafe("onSubmitMPIN triggered");
@ -64,19 +47,19 @@ class MPINController extends GetxController {
}
final enteredMPIN = digitControllers.map((c) => c.text).join();
logSafe("Entered MPIN: $enteredMPIN");
logSafe("Entered MPIN: $enteredMPIN", );
if (enteredMPIN.length < 4) {
_showError("Please enter all 4 digits.");
if (enteredMPIN.length < 6) {
_showError("Please enter all 6 digits.");
return;
}
if (isNewUser.value || isChangeMpin.value) {
if (isNewUser.value) {
final retypeMPIN = retypeControllers.map((c) => c.text).join();
logSafe("Retyped MPIN: $retypeMPIN");
logSafe("Retyped MPIN: $retypeMPIN", );
if (retypeMPIN.length < 4) {
_showError("Please enter all 4 digits in Retype MPIN.");
if (retypeMPIN.length < 6) {
_showError("Please enter all 6 digits in Retype MPIN.");
return;
}
@ -87,20 +70,19 @@ class MPINController extends GetxController {
return;
}
logSafe("MPINs matched. Proceeding to generate MPIN.");
final bool success = await generateMPIN(mpin: enteredMPIN);
if (success) {
logSafe("MPIN generation/change successful.");
logSafe("MPIN generation successful.");
showAppSnackbar(
title: "Success",
message: isChangeMpin.value
? "MPIN changed successfully."
: "MPIN generated successfully. Please login again.",
message: "MPIN generated successfully. Please login again.",
type: SnackbarType.success,
);
await LocalStorage.logout();
} else {
logSafe("MPIN generation/change failed.", level: LogLevel.warning);
logSafe("MPIN generation failed.", level: LogLevel.warning);
clearFields();
clearRetypeFields();
}
@ -110,25 +92,20 @@ class MPINController extends GetxController {
}
}
/// Forgot MPIN
Future<void> onForgotMPIN() async {
logSafe("onForgotMPIN called");
isNewUser.value = true;
isChangeMpin.value = false;
clearFields();
clearRetypeFields();
}
/// Switch to login/enter MPIN screen
void switchToEnterMPIN() {
logSafe("switchToEnterMPIN called");
isNewUser.value = false;
isChangeMpin.value = false;
clearFields();
clearRetypeFields();
}
/// Show error snackbar
void _showError(String message) {
logSafe("ERROR: $message", level: LogLevel.error);
showAppSnackbar(
@ -138,21 +115,18 @@ class MPINController extends GetxController {
);
}
/// Navigate to dashboard
/// Navigate to tenant selection after MPIN verification
void _navigateToTenantSelection({String? message}) {
void _navigateToDashboard({String? message}) {
if (message != null) {
logSafe("Navigating to Tenant Selection with message: $message");
logSafe("Navigating to Dashboard with message: $message");
showAppSnackbar(
title: "Success",
message: message,
type: SnackbarType.success,
);
}
Get.offAllNamed('/select-tenant');
Get.offAll(() => const DashboardScreen());
}
/// Clear the primary MPIN fields
void clearFields() {
logSafe("clearFields called");
for (final c in digitControllers) {
@ -161,7 +135,6 @@ class MPINController extends GetxController {
focusNodes.first.requestFocus();
}
/// Clear the retype MPIN fields
void clearRetypeFields() {
logSafe("clearRetypeFields called");
for (final c in retypeControllers) {
@ -170,7 +143,6 @@ class MPINController extends GetxController {
retypeFocusNodes.first.requestFocus();
}
/// Cleanup
@override
void onClose() {
logSafe("onClose called");
@ -189,8 +161,9 @@ class MPINController extends GetxController {
super.onClose();
}
/// Generate MPIN for new user/change MPIN
Future<bool> generateMPIN({required String mpin}) async {
Future<bool> generateMPIN({
required String mpin,
}) async {
try {
isLoading.value = true;
logSafe("generateMPIN started");
@ -204,7 +177,7 @@ class MPINController extends GetxController {
return false;
}
logSafe("Calling AuthService.generateMpin for employeeId: $employeeId");
logSafe("Calling AuthService.generateMpin for employeeId: $employeeId", );
final response = await AuthService.generateMpin(
employeeId: employeeId,
@ -214,12 +187,21 @@ class MPINController extends GetxController {
isLoading.value = false;
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;
} else {
logSafe("MPIN generation returned error: $response",
level: LogLevel.warning);
logSafe("MPIN generation returned error: $response", level: LogLevel.warning);
showAppSnackbar(
title: "MPIN Operation Failed",
title: "MPIN Generation Failed",
message: "Please check your inputs.",
type: SnackbarType.error,
);
@ -231,22 +213,24 @@ class MPINController extends GetxController {
} catch (e) {
isLoading.value = false;
logSafe("Exception in generateMPIN", level: LogLevel.error, error: e);
_showError("Failed to process MPIN.");
_showError("Failed to generate MPIN.");
return false;
}
}
/// Verify MPIN for existing user
Future<void> verifyMPIN() async {
logSafe("verifyMPIN triggered");
final enteredMPIN = digitControllers.map((c) => c.text).join();
if (enteredMPIN.length < 4) {
_showError("Please enter all 4 digits.");
logSafe("Entered MPIN: $enteredMPIN", );
if (enteredMPIN.length < 6) {
_showError("Please enter all 6 digits.");
return;
}
final mpinToken = await LocalStorage.getMpinToken();
if (mpinToken == null || mpinToken.isEmpty) {
_showError("Missing MPIN token. Please log in again.");
return;
@ -255,12 +239,9 @@ class MPINController extends GetxController {
try {
isLoading.value = true;
final fcmToken = await FirebaseNotificationService().getFcmToken();
final response = await AuthService.verifyMpin(
mpin: enteredMPIN,
mpinToken: mpinToken,
fcmToken: fcmToken ?? '',
);
isLoading.value = false;
@ -269,29 +250,15 @@ class MPINController extends GetxController {
logSafe("MPIN verified successfully");
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(
title: "Success",
message: "MPIN Verified Successfully",
type: SnackbarType.success,
);
_navigateToTenantSelection();
_navigateToDashboard();
} else {
final errorMessage = response["error"] ?? "Invalid MPIN";
logSafe("MPIN verification failed: $errorMessage",
level: LogLevel.warning);
logSafe("MPIN verification failed: $errorMessage", level: LogLevel.warning);
showAppSnackbar(
title: "Error",
message: errorMessage,
@ -303,11 +270,14 @@ class MPINController extends GetxController {
} catch (e) {
isLoading.value = false;
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() {
failedAttempts.value++;
if (failedAttempts.value >= 3) {

View File

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

View File

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

View File

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

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/widgets/my_snackbar.dart';
import 'package:marco/controller/directory/directory_controller.dart';
import 'package:marco/controller/directory/notes_controller.dart';
class AddCommentController extends GetxController {
final String contactId;
@ -40,10 +39,6 @@ class AddCommentController extends GetxController {
final directoryController = Get.find<DirectoryController>();
await directoryController.fetchCommentsForContact(contactId);
final notesController = Get.find<NotesController>();
await notesController.fetchNotes(
pageSize: 1000, pageNumber: 1); // Fixed here
Get.back(result: true);
showAppSnackbar(
@ -51,6 +46,13 @@ class AddCommentController extends GetxController {
message: "Your comment has been successfully added.",
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) {
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 RxString selectedCategory = ''.obs;
final RxList<String> selectedBuckets = <String>[].obs;
final RxString selectedBucket = ''.obs;
final RxString selectedProject = ''.obs;
final RxList<String> enteredTags = <String>[].obs;
@ -24,7 +24,6 @@ class AddContactController extends GetxController {
final RxMap<String, String> tagsMap = <String, String>{}.obs;
final RxBool isInitialized = false.obs;
final RxList<String> selectedProjects = <String>[].obs;
final RxBool isSubmitting = false.obs;
@override
void onInit() {
@ -50,7 +49,7 @@ class AddContactController extends GetxController {
void resetForm() {
selectedCategory.value = '';
selectedProject.value = '';
selectedBuckets.clear();
selectedBucket.value = '';
enteredTags.clear();
filteredSuggestions.clear();
filteredOrgSuggestions.clear();
@ -94,39 +93,21 @@ class AddContactController extends GetxController {
required List<Map<String, String>> phones,
required String address,
required String description,
String? designation,
}) async {
if (isSubmitting.value) return;
isSubmitting.value = true;
final categoryId = categoriesMap[selectedCategory.value];
final bucketIds = selectedBuckets
.map((name) => bucketsMap[name])
.whereType<String>()
.toList();
if (bucketIds.isEmpty) {
showAppSnackbar(
title: "Missing Buckets",
message: "Please select at least one bucket.",
type: SnackbarType.warning,
);
isSubmitting.value = false;
return;
}
final bucketId = bucketsMap[selectedBucket.value];
final projectIds = selectedProjects
.map((name) => projectsMap[name])
.whereType<String>()
.toList();
// === Required validations only for name, organization, and bucket ===
if (name.trim().isEmpty) {
showAppSnackbar(
title: "Missing Name",
message: "Please enter the contact name.",
type: SnackbarType.warning,
);
isSubmitting.value = false;
return;
}
@ -136,20 +117,19 @@ class AddContactController extends GetxController {
message: "Please enter the organization name.",
type: SnackbarType.warning,
);
isSubmitting.value = false;
return;
}
if (selectedBuckets.isEmpty) {
if (selectedBucket.value.trim().isEmpty || bucketId == null) {
showAppSnackbar(
title: "Missing Bucket",
message: "Please select at least one bucket.",
message: "Please select a bucket.",
type: SnackbarType.warning,
);
isSubmitting.value = false;
return;
}
// === Build body (include optional fields if available) ===
try {
final tagObjects = enteredTags.map((tagName) {
final tagId = tagsMap[tagName];
@ -165,14 +145,12 @@ class AddContactController extends GetxController {
if (selectedCategory.value.isNotEmpty && categoryId != null)
"contactCategoryId": categoryId,
if (projectIds.isNotEmpty) "projectIds": projectIds,
"bucketIds": bucketIds,
"bucketIds": [bucketId],
if (enteredTags.isNotEmpty) "tags": tagObjects,
if (emails.isNotEmpty) "contactEmails": emails,
if (phones.isNotEmpty) "contactPhones": phones,
if (address.trim().isNotEmpty) "address": address.trim(),
if (description.trim().isNotEmpty) "description": description.trim(),
if (designation != null && designation.trim().isNotEmpty)
"designation": designation.trim(),
};
logSafe("${id != null ? 'Updating' : 'Creating'} contact");
@ -204,8 +182,6 @@ class AddContactController extends GetxController {
message: "Something went wrong",
type: SnackbarType.error,
);
} finally {
isSubmitting.value = false;
}
}

View File

@ -1,13 +1,12 @@
import 'package:get/get.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/directory/contact_model.dart';
import 'package:marco/model/directory/contact_bucket_list_model.dart';
import 'package:marco/model/directory/directory_comment_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class DirectoryController extends GetxController {
// -------------------- CONTACTS --------------------
RxList<ContactModel> allContacts = <ContactModel>[].obs;
RxList<ContactModel> filteredContacts = <ContactModel>[].obs;
RxList<ContactCategory> contactCategories = <ContactCategory>[].obs;
@ -17,10 +16,16 @@ class DirectoryController extends GetxController {
RxBool isLoading = false.obs;
RxList<ContactBucket> contactBuckets = <ContactBucket>[].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>();
@override
@ -29,75 +34,26 @@ class DirectoryController extends GetxController {
fetchContacts();
fetchBuckets();
}
// -------------------- 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;
}
// inside DirectoryController
Future<void> updateComment(DirectoryComment comment) async {
try {
final existing = getCommentsForContact(comment.contactId)
.firstWhereOrNull((c) => c.id == comment.id);
logSafe(
"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(
title: "No Changes",
message: "No changes were made to the comment.",
@ -107,26 +63,32 @@ class DirectoryController extends GetxController {
}
final success = await ApiService.updateContactComment(
comment.id, comment.note, comment.contactId);
comment.id,
comment.note,
comment.contactId,
);
if (success) {
await fetchCommentsForContact(comment.contactId, active: true);
await fetchCommentsForContact(comment.contactId, active: false);
logSafe("Comment updated successfully. id: ${comment.id}");
await fetchCommentsForContact(comment.contactId);
// Show success message
showAppSnackbar(
title: "Success",
message: "Comment updated successfully.",
type: SnackbarType.success,
);
} else {
logSafe("Failed to update comment via API. id: ${comment.id}");
showAppSnackbar(
title: "Error",
message: "Failed to update comment.",
type: SnackbarType.error,
);
}
} catch (e, stack) {
logSafe("Update comment failed: $e", level: LogLevel.error);
logSafe(stack.toString(), level: LogLevel.debug);
} catch (e, stackTrace) {
logSafe("Update comment failed: ${e.toString()}");
logSafe("StackTrace: ${stackTrace.toString()}");
showAppSnackbar(
title: "Error",
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 {
final success = await ApiService.restoreContactComment(commentId, false);
final data = await ApiService.getDirectoryComments(contactId);
logSafe("Fetched comments for contact $contactId: $data");
if (success) {
if (editingCommentId.value == commentId) editingCommentId.value = null;
await fetchCommentsForContact(contactId, active: true);
await fetchCommentsForContact(contactId, active: false);
showAppSnackbar(
title: "Deleted",
message: "Comment deleted successfully.",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to delete comment.",
type: SnackbarType.error,
);
}
} catch (e, stack) {
logSafe("Delete comment failed: $e", level: LogLevel.error);
logSafe(stack.toString(), level: LogLevel.debug);
showAppSnackbar(
title: "Error",
message: "Something went wrong while deleting comment.",
type: SnackbarType.error,
);
}
final comments =
data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? [];
if (!contactCommentsMap.containsKey(contactId)) {
contactCommentsMap[contactId] = <DirectoryComment>[].obs;
}
Future<void> restoreComment(String commentId, String contactId) async {
try {
final success = await ApiService.restoreContactComment(commentId, true);
contactCommentsMap[contactId]!.assignAll(comments);
contactCommentsMap[contactId]?.refresh();
} catch (e) {
logSafe("Error fetching comments for contact $contactId: $e",
level: LogLevel.error);
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,
);
contactCommentsMap[contactId] ??= <DirectoryComment>[].obs;
contactCommentsMap[contactId]!.clear();
}
}
// -------------------- CONTACTS HANDLING --------------------
Future<void> fetchBuckets() async {
try {
final response = await ApiService.getContactBucketList();
@ -213,71 +135,11 @@ class DirectoryController extends GetxController {
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 {
try {
isLoading.value = true;
final response = await ApiService.getDirectoryData(isActive: active);
if (response != null) {
@ -298,12 +160,14 @@ class DirectoryController extends GetxController {
void extractCategoriesFromContacts() {
final uniqueCategories = <String, ContactCategory>{};
for (final contact in allContacts) {
final category = contact.contactCategory;
if (category != null) {
uniqueCategories.putIfAbsent(category.id, () => category);
if (category != null && !uniqueCategories.containsKey(category.id)) {
uniqueCategories[category.id] = category;
}
}
contactCategories.value = uniqueCategories.values.toList();
}
@ -318,14 +182,19 @@ class DirectoryController extends GetxController {
final bucketMatch = selectedBuckets.isEmpty ||
contact.bucketIds.any((id) => selectedBuckets.contains(id));
// Name, org, email, phone, tags
final nameMatch = contact.name.toLowerCase().contains(query);
final orgMatch = contact.organization.toLowerCase().contains(query);
final emailMatch = contact.contactEmails
.any((e) => e.emailAddress.toLowerCase().contains(query));
final phoneMatch = contact.contactPhones
.any((p) => p.phoneNumber.toLowerCase().contains(query));
final tagMatch =
contact.tags.any((tag) => tag.name.toLowerCase().contains(query));
final categoryNameMatch =
contact.contactCategory?.name.toLowerCase().contains(query) ?? false;
@ -349,9 +218,6 @@ class DirectoryController extends GetxController {
return categoryMatch && bucketMatch && searchMatch;
}).toList();
filteredContacts
.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
}
void toggleCategory(String categoryId) {

View File

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

View File

@ -6,7 +6,7 @@ import 'package:marco/helpers/services/api_service.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.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 'dart:io';
import 'dart:convert';
@ -14,7 +14,7 @@ import 'package:marco/helpers/widgets/my_image_compressor.dart';
enum ApiStatus { idle, loading, success, failure }
final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController());
final DailyTaskPlaningController taskController = Get.put(DailyTaskPlaningController());
final ImagePicker _picker = ImagePicker();
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 {
static const String baseUrl = "https://stageapi.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
static const String getDashboardAttendanceOverview =
"/dashboard/attendance-overview";
static const String getDashboardProjectProgress = "/dashboard/progression";
static const String getDashboardTasks = "/dashboard/tasks";
static const String getDashboardTeams = "/dashboard/teams";
static const String getDashboardProjects = "/dashboard/projects";
// Dashboard Screen API Endpoints
static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview";
// Attendance Module API Endpoints
// Attendance Screen API Endpoints
static const String getProjects = "/project/list";
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 getAttendanceLogView = "/attendance/log/attendance";
static const String getRegularizationLogs = "/attendance/regularize";
static const String uploadAttendanceImage = "/attendance/record-image";
// Employee Screen API Endpoints
static const String getAllEmployeesByProject = "/employee/list";
static const String getAllEmployeesByOrganization = "/project/get/task/team";
static const String getAllEmployeesByProject = "/Project/employees/get";
static const String getAllEmployees = "/employee/list";
static const String getEmployeesWithoutPermission = "/employee/basic";
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 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 reportTask = "/task/report";
static const String commentTask = "/task/comment";
@ -42,62 +31,19 @@ class ApiEndpoints {
static const String approveReportAction = "/task/approve";
static const String assignTask = "/project/task";
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 getDirectoryBucketList = "/directory/buckets";
static const String getDirectoryContactDetail = "/directory/notes";
static const String getDirectoryContactCategory =
"/master/contact-categories";
static const String getDirectoryContactCategory = "/master/contact-categories";
static const String getDirectoryContactTags = "/master/contact-tags";
static const String getDirectoryOrganization = "/directory/organization";
static const String createContact = "/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 updateDirectoryNotes = "/directory/note";
static const String createBucket = "/directory/bucket";
static const String updateBucket = "/directory/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:url_strategy/url_strategy.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:get/get.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/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/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 {
try {
logSafe("💡 Starting app initialization...");
await Future.wait([
_setupUI(),
_setupFirebase(),
_setupLocalStorage(),
]);
setPathUrlStrategy();
logSafe("💡 URL strategy set.");
await _setupDeviceInfo();
await _handleAuthTokens();
await _setupTheme();
await _setupFirebaseMessaging();
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Color.fromARGB(255, 255, 0, 0),
statusBarIconBrightness: Brightness.light,
));
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.");
} catch (e, stacktrace) {
@ -37,57 +74,3 @@ Future<void> initializeApp() async {
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 'package:logger/logger.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:permission_handler/permission_handler.dart';
/// Global logger instance
Logger? _appLogger;
late final FileLogOutput _fileLogOutput;
late final Logger appLogger;
/// Store logs temporarily for API posting
final List<Map<String, dynamic>> _logBuffer = [];
/// Log file output handler
late final FileLogOutput fileLogOutput;
/// Lock flag to prevent concurrent posting
bool _isPosting = false;
/// Flag to allow API posting only after login
bool _canPostLogs = false;
/// Maximum number of logs before triggering API post
const int _maxLogsBeforePost = 100;
/// Maximum logs in memory buffer
const int _maxBufferSize = 500;
/// Enum logger level mapping
const _levelMap = {
LogLevel.debug: Level.debug,
LogLevel.info: Level.info,
LogLevel.warning: Level.warning,
LogLevel.error: Level.error,
LogLevel.verbose: Level.verbose,
};
/// Initialize logging
/// Initialize logging (call once in `main()`)
Future<void> initLogging() async {
_fileLogOutput = FileLogOutput();
await requestStoragePermission();
_appLogger = Logger(
fileLogOutput = FileLogOutput();
appLogger = Logger(
printer: PrettyPrinter(
methodCount: 0,
printTime: true,
@ -44,17 +23,19 @@ Future<void> initLogging() async {
printEmojis: true,
),
output: MultiOutput([
ConsoleOutput(),
_fileLogOutput,
ConsoleOutput(), // Console will use the top-level PrettyPrinter
fileLogOutput, // File will still use the SimpleFileLogPrinter
]),
level: Level.debug,
);
}
/// Enable API posting after login
void enableRemoteLogging() {
_canPostLogs = true;
_postBufferedLogs(); // flush logs if any
/// Request storage permission (for Android 11+)
Future<void> requestStoragePermission() async {
final status = await Permission.manageExternalStorage.status;
if (!status.isGranted) {
await Permission.manageExternalStorage.request();
}
}
/// Safe logger wrapper
@ -65,68 +46,35 @@ void logSafe(
StackTrace? stackTrace,
bool sensitive = false,
}) {
if (sensitive || _appLogger == null) return;
if (sensitive) return;
final loggerLevel = _levelMap[level] ?? Level.info;
_appLogger!.log(loggerLevel, message, error: error, stackTrace: stackTrace);
// Buffer logs for API posting
_logBuffer.add({
"logLevel": level.name,
"message": message,
"timeStamp": DateTime.now().toUtc().toIso8601String(),
"ipAddress": "this is test IP", // TODO: real IP
"userAgent": "FlutterApp/1.0", // TODO: device_info_plus
"details": error?.toString() ?? stackTrace?.toString(),
});
if (_logBuffer.length >= _maxLogsBeforePost) {
_postBufferedLogs();
switch (level) {
case LogLevel.debug:
appLogger.d(message, error: error, stackTrace: stackTrace);
break;
case LogLevel.warning:
appLogger.w(message, error: error, stackTrace: stackTrace);
break;
case LogLevel.error:
appLogger.e(message, error: error, stackTrace: stackTrace);
break;
case LogLevel.verbose:
appLogger.v(message, error: error, stackTrace: stackTrace);
break;
default:
appLogger.i(message, error: error, stackTrace: stackTrace);
}
}
/// Post buffered logs to API
Future<void> _postBufferedLogs() async {
if (!_canPostLogs) return; // 🚫 skip if not logged in
if (_isPosting || _logBuffer.isEmpty) return;
_isPosting = true;
final logsToSend = List<Map<String, dynamic>>.from(_logBuffer);
_logBuffer.clear();
try {
final success = await ApiService.postLogsApi(logsToSend);
if (!success) {
_reinsertLogs(logsToSend, reason: "API call returned false");
}
} catch (e) {
_reinsertLogs(logsToSend, reason: "API exception: $e");
} finally {
_isPosting = false;
}
}
/// Reinsert logs into buffer if posting fails
void _reinsertLogs(List<Map<String, dynamic>> logs, {required String reason}) {
_appLogger?.w("Failed to post logs, re-queuing. Reason: $reason");
if (_logBuffer.length + logs.length > _maxBufferSize) {
_appLogger?.e("Buffer full. Dropping ${logs.length} logs to prevent crash.");
return;
}
_logBuffer.insertAll(0, logs);
}
/// File-based log output (safe storage)
/// Custom log output that writes to a local `.txt` file
class FileLogOutput extends LogOutput {
File? _logFile;
/// Initialize log file in Downloads/marco_logs/log_YYYY-MM-DD.txt
Future<void> _init() async {
if (_logFile != null) return;
final baseDir = await getExternalStorageDirectory();
final directory = Directory('${baseDir!.path}/marco_logs');
final directory = Directory('/storage/emulated/0/Download/marco_logs');
if (!await directory.exists()) {
await directory.create(recursive: true);
}
@ -145,6 +93,7 @@ class FileLogOutput extends LogOutput {
@override
void output(OutputEvent event) async {
await _init();
if (event.lines.isEmpty) return;
final logMessage = event.lines.join('\n') + '\n';
@ -170,6 +119,7 @@ class FileLogOutput extends LogOutput {
return _logFile!.readAsString();
}
/// Delete logs older than 3 days
Future<void> _cleanOldLogs(Directory directory) async {
final files = directory.listSync();
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 }

View File

@ -1,5 +1,9 @@
import 'dart:convert';
import 'package:get/get.dart';
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/storage/local_storage.dart';
import 'package:marco/helpers/services/app_logger.dart';
@ -11,282 +15,277 @@ class AuthService {
};
static bool isLoggedIn = false;
/* -------------------------------------------------------------------------- */
/* Logout API */
/* -------------------------------------------------------------------------- */
static Future<bool> logoutApi(String refreshToken, String fcmToken) async {
/// Login with email and password
static Future<Map<String, String>?> loginUser(Map<String, dynamic> data) async {
try {
final body = {
"refreshToken": refreshToken,
"fcmToken": fcmToken,
};
final response = await _post("/auth/logout", body);
if (response != null && response['statusCode'] == 200) {
logSafe("✅ Logout API successful");
return true;
}
logSafe("⚠️ Logout API failed: ${response?['message']}",
level: LogLevel.warning);
return false;
} catch (e, st) {
_handleError("Logout API error", e, st);
return false;
}
}
/* -------------------------------------------------------------------------- */
/* Public Methods */
/* -------------------------------------------------------------------------- */
static Future<bool> registerDeviceToken(String fcmToken) async {
final token = await LocalStorage.getJwtToken();
if (token == null || token.isEmpty) {
logSafe("❌ Cannot register device token: missing JWT token",
level: LogLevel.warning);
return false;
}
final body = {"fcmToken": fcmToken};
final headers = {
..._headers,
'Authorization': 'Bearer $token',
};
final endpoint = "$_baseUrl/auth/set/device-token";
// 🔹 Log request details
logSafe("📡 Device Token API Request");
logSafe("➡️ Endpoint: $endpoint");
logSafe("➡️ Headers: ${jsonEncode(headers)}");
logSafe("➡️ Payload: ${jsonEncode(body)}");
final data = await _post("/auth/set/device-token", body, authToken: token);
if (data != null && data['success'] == true) {
logSafe("✅ Device token registered successfully.");
return true;
}
logSafe("⚠️ Failed to register device token: ${data?['message']}",
level: LogLevel.warning);
return false;
}
static Future<Map<String, String>?> loginUser(
Map<String, dynamic> data) async {
logSafe("Attempting login...");
logSafe("Login payload (raw): $data");
logSafe("Login payload (JSON): ${jsonEncode(data)}");
final response = await http.post(
Uri.parse("$_baseUrl/auth/login-mobile"),
headers: _headers,
body: jsonEncode(data),
);
final responseData = await _post("/auth/app/login", data);
if (responseData == null)
return {"error": "Network error. Please check your connection."};
if (responseData['data'] != null) {
final responseData = jsonDecode(response.body);
if (response.statusCode == 200 && responseData['data'] != null) {
await _handleLoginSuccess(responseData['data']);
return null;
}
if (responseData['statusCode'] == 401) {
} 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("Login exception", level: LogLevel.error, error: e, stackTrace: stacktrace);
return {"error": "Network error. Please check your connection."};
}
}
/// Refresh JWT token
static Future<bool> refreshToken() async {
final accessToken = LocalStorage.getJwtToken();
final refreshToken = LocalStorage.getRefreshToken();
final accessToken = await LocalStorage.getJwtToken();
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);
return false;
}
final body = {"token": accessToken, "refreshToken": refreshToken};
final data = await _post("/auth/refresh-token", body);
if (data != null && data['success'] == true) {
final requestBody = {
"token": accessToken,
"refreshToken": refreshToken,
};
try {
logSafe("Refreshing token...");
final response = await http.post(
Uri.parse("$_baseUrl/auth/refresh-token"),
headers: _headers,
body: jsonEncode(requestBody),
);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) {
await LocalStorage.setJwtToken(data['data']['token']);
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
await LocalStorage.setLoggedInUser(true);
logSafe("Token refreshed successfully.");
// 🔹 Retry FCM token registration after token refresh
final newFcmToken = LocalStorage.getFcmToken();
if (newFcmToken?.isNotEmpty ?? false) {
final success = await registerDeviceToken(newFcmToken!);
logSafe(
success
? "✅ FCM token re-registered after JWT refresh."
: "⚠️ Failed to register FCM token after JWT refresh.",
level: success ? LogLevel.info : LogLevel.warning);
}
return true;
}
logSafe("Refresh token failed: ${data?['message']}",
level: LogLevel.warning);
} else {
logSafe("Refresh token failed: ${data['message']}", level: LogLevel.warning);
return false;
}
} catch (e, stacktrace) {
logSafe("Token refresh exception", level: LogLevel.error, error: e, stackTrace: stacktrace);
return false;
}
}
static Future<Map<String, String>?> forgotPassword(String email) =>
_wrapErrorHandling(() => _post("/auth/forgot-password", {"email": email}),
successCondition: (data) => data['success'] == true,
defaultError: "Failed to send reset link.");
/// Forgot password
static Future<Map<String, String>?> forgotPassword(String email) async {
try {
logSafe("Forgot password requested.");
final response = await http.post(
Uri.parse("$_baseUrl/auth/forgot-password"),
headers: _headers,
body: jsonEncode({"email": email}),
);
static Future<Map<String, String>?> requestDemo(
Map<String, dynamic> demoData) =>
_wrapErrorHandling(() => _post("/market/inquiry", demoData),
successCondition: (data) => data['success'] == true,
defaultError: "Failed to submit demo request.");
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) return null;
return {"error": data['message'] ?? "Failed to send reset link."};
} catch (e, stacktrace) {
logSafe("Forgot password error", level: LogLevel.error, error: e, stackTrace: stacktrace);
return {"error": "Network error. Please check your connection."};
}
}
/// 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 {
final data = await _get("/market/industries");
if (data != null && data['success'] == true) {
try {
logSafe("Fetching industries list...");
final response = await http.get(
Uri.parse("$_baseUrl/market/industries"),
headers: _headers,
);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) {
return List<Map<String, dynamic>>.from(data['data']);
}
return null;
} catch (e, stacktrace) {
logSafe("Get industries error", level: LogLevel.error, error: e, stackTrace: stacktrace);
return null;
}
}
/// Generate MPIN
static Future<Map<String, String>?> generateMpin({
required String employeeId,
required String mpin,
}) =>
_wrapErrorHandling(
() async {
final token = LocalStorage.getJwtToken();
return _post(
"/auth/generate-mpin",
{"employeeId": employeeId, "mpin": mpin},
authToken: token,
);
}) async {
final token = await LocalStorage.getJwtToken();
try {
logSafe("Generating MPIN...");
final response = await http.post(
Uri.parse("$_baseUrl/auth/generate-mpin"),
headers: {
..._headers,
if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token',
},
successCondition: (data) => data['success'] == true,
defaultError: "Failed to generate MPIN.",
body: jsonEncode({"employeeId": employeeId, "mpin": mpin}),
);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) return null;
return {"error": data['message'] ?? "Failed to generate MPIN."};
} catch (e, stacktrace) {
logSafe("Generate MPIN error", level: LogLevel.error, error: e, stackTrace: stacktrace);
return {"error": "Network error. Please check your connection."};
}
}
/// Verify MPIN
static Future<Map<String, String>?> verifyMpin({
required String mpin,
required String mpinToken,
required String fcmToken,
}) =>
_wrapErrorHandling(
() async {
}) async {
final employeeInfo = LocalStorage.getEmployeeInfo();
if (employeeInfo == null) return null;
if (employeeInfo == null) return {"error": "Employee info not found."};
final token = await LocalStorage.getJwtToken();
return _post(
"/auth/login-mpin",
{
try {
logSafe("Verifying MPIN...");
final response = await http.post(
Uri.parse("$_baseUrl/auth/login-mpin"),
headers: {
..._headers,
if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token',
},
body: jsonEncode({
"employeeId": employeeInfo.id,
"mpin": mpin,
"mpinToken": mpinToken,
"fcmToken": fcmToken,
},
authToken: token,
);
},
successCondition: (data) => data['success'] == true,
defaultError: "MPIN verification failed.",
}),
);
static Future<Map<String, String>?> generateOtp(String email) =>
_wrapErrorHandling(() => _post("/auth/send-otp", {"email": email}),
successCondition: (data) => data['success'] == true,
defaultError: "Failed to generate OTP.");
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) return null;
return {"error": data['message'] ?? "MPIN verification failed."};
} catch (e, stacktrace) {
logSafe("Verify MPIN error", level: LogLevel.error, error: e, stackTrace: stacktrace);
return {"error": "Network error. Please check your connection."};
}
}
/// Generate OTP
static Future<Map<String, String>?> generateOtp(String email) async {
try {
logSafe("Generating OTP for email...");
final response = await http.post(
Uri.parse("$_baseUrl/auth/send-otp"),
headers: _headers,
body: jsonEncode({"email": email}),
);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) return null;
return {"error": data['message'] ?? "Failed to generate OTP."};
} catch (e, stacktrace) {
logSafe("Generate OTP error", level: LogLevel.error, error: e, stackTrace: stacktrace);
return {"error": "Network error. Please check your connection."};
}
}
/// Verify OTP and login
static Future<Map<String, String>?> verifyOtp({
required String email,
required String otp,
}) async {
final data = await _post("/auth/login-otp", {"email": email, "otp": otp});
if (data != null && data['data'] != null) {
try {
logSafe("Verifying OTP...");
final response = await http.post(
Uri.parse("$_baseUrl/auth/login-otp"),
headers: _headers,
body: jsonEncode({"email": email, "otp": otp}),
);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && 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 {
try {
final headers = {
..._headers,
if (authToken?.isNotEmpty ?? false)
'Authorization': 'Bearer $authToken',
};
final response = await http.post(Uri.parse("$_baseUrl$path"),
headers: headers, body: jsonEncode(body));
return {
...jsonDecode(response.body),
"statusCode": response.statusCode,
};
} catch (e, st) {
_handleError("$path POST error", e, st);
return null;
return {"error": data['message'] ?? "OTP verification failed."};
} catch (e, stacktrace) {
logSafe("Verify OTP error", level: LogLevel.error, error: e, stackTrace: stacktrace);
return {"error": "Network error. Please check your connection."};
}
}
static Future<Map<String, dynamic>?> _get(
String path, {
String? authToken,
}) async {
try {
final headers = {
..._headers,
if (authToken?.isNotEmpty ?? false)
'Authorization': 'Bearer $authToken',
};
final response =
await http.get(Uri.parse("$_baseUrl$path"), headers: headers);
return {
...jsonDecode(response.body),
"statusCode": response.statusCode,
};
} catch (e, st) {
_handleError("$path GET error", e, st);
return null;
}
}
static Future<Map<String, String>?> _wrapErrorHandling(
Future<Map<String, dynamic>?> Function() request, {
required bool Function(Map<String, dynamic> data) successCondition,
required String defaultError,
}) async {
final data = await request();
if (data != null && successCondition(data)) return null;
return {"error": data?['message'] ?? defaultError};
}
static void _handleError(String message, Object error, StackTrace st) {
logSafe(message, level: LogLevel.error, error: error, stackTrace: st);
}
/// Handle login success flow
static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async {
logSafe("Processing login success...");
await LocalStorage.setJwtToken(data['token']);
final jwtToken = data['token'];
final refreshToken = data['refreshToken'];
final mpinToken = data['mpinToken'];
// Save tokens
await LocalStorage.setJwtToken(jwtToken);
await LocalStorage.setLoggedInUser(true);
if (data['refreshToken'] != null) {
await LocalStorage.setRefreshToken(data['refreshToken']);
if (refreshToken != null) {
await LocalStorage.setRefreshToken(refreshToken);
}
if (data['mpinToken']?.isNotEmpty ?? false) {
await LocalStorage.setMpinToken(data['mpinToken']);
if (mpinToken != null && mpinToken.isNotEmpty) {
await LocalStorage.setMpinToken(mpinToken);
await LocalStorage.setIsMpin(true);
} else {
await LocalStorage.setIsMpin(false);
await LocalStorage.removeMpinToken();
}
// Inject controllers if not already registered
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
logSafe("✅ PermissionController injected after login.");
}
if (!Get.isRegistered<ProjectController>()) {
Get.put(ProjectController(), permanent: true);
logSafe("✅ ProjectController injected after login.");
}
// Load data into controllers
await Get.find<PermissionController>().loadData(jwtToken);
await Get.find<ProjectController>().fetchProjects();
isLoggedIn = true;
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/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/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/api_endpoints.dart';
class PermissionService {
// In-memory cache keyed by user token
static final Map<String, Map<String, dynamic>> _userDataCache = {};
static const String _baseUrl = ApiEndpoints.baseUrl;
/// Fetches all user-related data (permissions, employee info, projects).
/// Uses in-memory cache for repeated token queries during session.
/// Fetches all user-related data (permissions, employee info, projects)
static Future<Map<String, dynamic>> fetchAllUserData(
String token, {
bool hasRetried = false,
}) async {
logSafe("Fetching user data...");
logSafe("Fetching user data...", );
// Check for cached data before network request
final cached = _userDataCache[token];
if (cached != null) {
logSafe("User data cache hit.");
return cached;
if (_userDataCache.containsKey(token)) {
logSafe("User data cache hit.", );
return _userDataCache[token]!;
}
final uri = Uri.parse("$_baseUrl/user/profile");
@ -38,8 +34,8 @@ class PermissionService {
final statusCode = response.statusCode;
if (statusCode == 200) {
final raw = json.decode(response.body);
final data = raw['data'] as Map<String, dynamic>;
logSafe("User data fetched successfully.");
final data = json.decode(response.body)['data'];
final result = {
'permissions': _parsePermissions(data['featurePermissions']),
@ -47,12 +43,10 @@ class PermissionService {
'projects': _parseProjectsInfo(data['projects']),
};
_userDataCache[token] = result; // Cache it for future use
logSafe("User data fetched successfully.");
_userDataCache[token] = result;
return result;
}
// Token expired, try refresh once then redirect on failure
if (statusCode == 401 && !hasRetried) {
logSafe("Unauthorized. Attempting token refresh...", level: LogLevel.warning);
@ -69,43 +63,42 @@ class PermissionService {
throw Exception('Unauthorized. Token refresh failed.');
}
final errorMsg = json.decode(response.body)['message'] ?? 'Unknown error';
logSafe("Failed to fetch user data: $errorMsg", level: LogLevel.warning);
throw Exception('Failed to fetch user data: $errorMsg');
final error = json.decode(response.body)['message'] ?? 'Unknown error';
logSafe("Failed to fetch user data: $error", level: LogLevel.warning);
throw Exception('Failed to fetch user data: $error');
} catch (e, 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 {
logSafe("Clearing tokens and redirecting to login due to unauthorized access.", level: LogLevel.warning);
await LocalStorage.removeToken('jwt_token');
await LocalStorage.removeToken('refresh_token');
await LocalStorage.setLoggedInUser(false);
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) {
logSafe("Parsing user permissions...");
return permissions
.map((perm) => UserPermission.fromJson({'id': perm}))
.map((id) => UserPermission.fromJson({'id': id}))
.toList();
}
/// Robust model parsing for employee info
static EmployeeInfo _parseEmployeeInfo(Map<String, dynamic>? data) {
/// Converts raw employee JSON into `EmployeeInfo`
static EmployeeInfo _parseEmployeeInfo(Map<String, dynamic> data) {
logSafe("Parsing employee info...");
if (data == null) throw Exception("Employee data missing");
return EmployeeInfo.fromJson(data);
}
/// Robust model parsing for projects list
static List<ProjectInfo> _parseProjectsInfo(List<dynamic>? projects) {
/// Converts raw projects JSON into list of `ProjectInfo`
static List<ProjectInfo> _parseProjectsInfo(List<dynamic> projects) {
logSafe("Parsing projects info...");
if (projects == null) return [];
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/localizations/language.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/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 {
static const String _loggedInUserKey = "user";
@ -20,125 +19,124 @@ class LocalStorage {
static const String _employeeInfoKey = "employee_info";
static const String _mpinTokenKey = "mpinToken";
static const String _isMpinKey = "isMpin";
static const String _fcmTokenKey = "fcm_token";
static 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 bool _initialized = false;
static bool get isInitialized => _initialized;
static SharedPreferences get preferences {
if (_preferencesInstance == null) {
throw ("Call LocalStorage.init() before using it");
throw ("Call LocalStorage.init() to initialize local storage");
}
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(
List<UserPermission> permissions) async {
// Convert the list of UserPermission objects to a List<Map<String, dynamic>>
final jsonList = permissions.map((e) => e.toJson()).toList();
// Save as a JSON string
return preferences.setString(_userPermissionsKey, jsonEncode(jsonList));
}
static List<UserPermission> getUserPermissions() {
if (!_initialized) return [];
final storedJson = preferences.getString(_userPermissionsKey);
if (storedJson == null) return [];
return (jsonDecode(storedJson) as List)
if (storedJson != null) {
final List<dynamic> parsedList = jsonDecode(storedJson);
return parsedList
.map((e) => UserPermission.fromJson(e as Map<String, dynamic>))
.toList();
}
static Future<bool> removeUserPermissions() =>
preferences.remove(_userPermissionsKey);
return [];
}
// ================== Employee Info ==================
static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) => preferences
.setString(_employeeInfoKey, jsonEncode(employeeInfo.toJson()));
static Future<bool> removeUserPermissions() async {
return preferences.remove(_userPermissionsKey);
}
// Store EmployeeInfo
static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) async {
final jsonData = employeeInfo.toJson();
return preferences.setString(_employeeInfoKey, jsonEncode(jsonData));
}
static EmployeeInfo? getEmployeeInfo() {
if (!_initialized) return null;
final storedJson = preferences.getString(_employeeInfoKey);
return storedJson == null
? null
: EmployeeInfo.fromJson(jsonDecode(storedJson));
if (storedJson != null) {
final Map<String, dynamic> json = jsonDecode(storedJson);
return EmployeeInfo.fromJson(json);
}
return null;
}
static Future<bool> removeEmployeeInfo() =>
preferences.remove(_employeeInfoKey);
// ================== Login / Logout ==================
static Future<bool> setLoggedInUser(bool loggedIn) =>
preferences.setBool(_loggedInUserKey, loggedIn);
static Future<bool> removeLoggedInUser() =>
preferences.remove(_loggedInUserKey);
static Future<void> logout() async {
try {
final refreshToken = getRefreshToken();
final fcmToken = getFcmToken();
if (refreshToken != null && fcmToken != null) {
await AuthService.logoutApi(refreshToken, fcmToken);
}
} catch (e) {
print("Logout API error: $e");
static Future<bool> removeEmployeeInfo() async {
return preferences.remove(_employeeInfoKey);
}
// Other methods for handling JWT, refresh token, etc.
static Future<void> init() async {
_preferencesInstance = await SharedPreferences.getInstance();
await initData();
}
static Future<void> initData() async {
SharedPreferences preferences = await SharedPreferences.getInstance();
AuthService.isLoggedIn = preferences.getBool(_loggedInUserKey) ?? false;
ThemeCustomizer.fromJSON(preferences.getString(_themeCustomizerKey));
}
static Future<bool> setLoggedInUser(bool loggedIn) async {
return preferences.setBool(_loggedInUserKey, loggedIn);
}
static Future<bool> setCustomizer(ThemeCustomizer themeCustomizer) {
return preferences.setString(_themeCustomizerKey, themeCustomizer.toJSON());
}
static Future<bool> setLanguage(Language language) {
return preferences.setString(_languageKey, language.locale.languageCode);
}
static String? getLanguage() {
return preferences.getString(_languageKey);
}
static Future<bool> removeLoggedInUser() async {
return preferences.remove(_loggedInUserKey);
}
// Add methods to handle JWT and Refresh Token
static Future<bool> setToken(String key, String token) {
return preferences.setString(key, token);
}
static String? getToken(String key) {
return preferences.getString(key);
}
static Future<bool> removeToken(String key) {
return preferences.remove(key);
}
// Convenience methods for getting the JWT and Refresh tokens
static String? getJwtToken() {
return getToken(_jwtTokenKey);
}
static String? getRefreshToken() {
return getToken(_refreshTokenKey);
}
static Future<bool> setJwtToken(String jwtToken) {
return setToken(_jwtTokenKey, jwtToken);
}
static Future<bool> setRefreshToken(String refreshToken) {
return setToken(_refreshTokenKey, refreshToken);
}
static Future<void> logout() async {
await removeLoggedInUser();
await removeToken(_jwtTokenKey);
await removeToken(_refreshTokenKey);
@ -146,83 +144,56 @@ class LocalStorage {
await removeEmployeeInfo();
await removeMpinToken();
await removeIsMpin();
await removeMenus();
await removeRecentTenantId();
await preferences.remove("mpin_verified");
await preferences.remove(_languageKey);
await preferences.remove(_themeCustomizerKey);
await preferences.remove('selectedProjectId');
if (Get.isRegistered<ProjectController>()) {
Get.find<ProjectController>().clearProjects();
}
Get.offAllNamed('/auth/login-option');
}
static Future<bool> setMpinToken(String token) {
return preferences.setString(_mpinTokenKey, token);
}
// ================== Theme & Language ==================
static Future<bool> setCustomizer(ThemeCustomizer themeCustomizer) =>
preferences.setString(_themeCustomizerKey, themeCustomizer.toJSON());
static String? getMpinToken() {
return preferences.getString(_mpinTokenKey);
}
static Future<bool> setLanguage(Language language) =>
preferences.setString(_languageKey, language.locale.languageCode);
static Future<bool> removeMpinToken() {
return preferences.remove(_mpinTokenKey);
}
static String? getLanguage() =>
_initialized ? preferences.getString(_languageKey) : null;
// MPIN Enabled flag
static Future<bool> setIsMpin(bool value) {
return preferences.setBool(_isMpinKey, value);
}
// ================== Tokens ==================
static Future<bool> setToken(String key, String token) =>
preferences.setString(key, token);
static bool getIsMpin() {
return preferences.getBool(_isMpinKey) ?? false;
}
static String? getToken(String key) =>
_initialized ? preferences.getString(key) : null;
static Future<bool> removeIsMpin() {
return preferences.remove(_isMpinKey);
}
static Future<bool> removeToken(String key) => preferences.remove(key);
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> setJwtToken(String jwtToken) =>
setToken(_jwtTokenKey, jwtToken);
static Future<bool> setRefreshToken(String refreshToken) =>
setToken(_refreshTokenKey, refreshToken);
static String? getJwtToken() => getToken(_jwtTokenKey);
static String? getRefreshToken() => getToken(_refreshTokenKey);
// ================== FCM Token ==================
static Future<void> setFcmToken(String token) =>
preferences.setString(_fcmTokenKey, token);
static String? getFcmToken() =>
_initialized ? preferences.getString(_fcmTokenKey) : null;
// ================== MPIN ==================
static Future<bool> setMpinToken(String token) =>
preferences.setString(_mpinTokenKey, token);
static String? getMpinToken() =>
_initialized ? preferences.getString(_mpinTokenKey) : null;
static Future<bool> removeMpinToken() => preferences.remove(_mpinTokenKey);
static Future<bool> setIsMpin(bool value) =>
preferences.setBool(_isMpinKey, value);
static bool getIsMpin() =>
_initialized ? preferences.getBool(_isMpinKey) ?? false : false;
static Future<bool> removeIsMpin() => preferences.remove(_isMpinKey);
// ================== Generic Set/Get ==================
static Future<bool> setBool(String key, bool value) =>
preferences.setBool(key, value);
static bool? getBool(String key) =>
_initialized ? preferences.getBool(key) : null;
static String? getString(String key) =>
_initialized ? preferences.getString(key) : null;
static Future<bool> saveString(String key, String value) =>
preferences.setString(key, value);
}

View File

@ -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,
cardRadius: AppStyle.cardRadius.medium,
buttonRadius: AppStyle.buttonRadius.medium,
defaultBreadCrumbItem: MyBreadcrumbItem(name: 'Marco', route: '/client/dashboard'),
defaultBreadCrumbItem: MyBreadcrumbItem(name: 'Marco', route: '/client/home'),
));
bool isMobile = true;
try {

View File

@ -24,8 +24,8 @@ class AttendanceActionColors {
ButtonActions.rejected: Colors.orange,
ButtonActions.approved: Colors.green,
ButtonActions.requested: Colors.yellow,
ButtonActions.approve: Colors.green,
ButtonActions.reject: Colors.red,
ButtonActions.approve: Colors.blueAccent,
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:marco/helpers/services/app_logger.dart';
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 {
logSafe('convertUtcToLocal: input="$utcTimeString", format="$format"');
final parsed = DateTime.parse(utcTimeString);
final utcDateTime = DateTime.utc(
parsed.year,
@ -16,35 +17,22 @@ class DateTimeUtils {
parsed.millisecond,
parsed.microsecond,
);
logSafe('Parsed (assumed UTC): $utcDateTime');
final localDateTime = utcDateTime.toLocal();
return _formatDateTime(localDateTime, format: format);
} catch (e) {
logSafe('Converted to Local: $localDateTime');
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';
}
}
/// Public utility for formatting any DateTime.
static String formatDate(DateTime date, String format) {
try {
return DateFormat(format).format(date);
} catch (e) {
return 'Invalid Date';
}
}
/// Parses a date string using the given format.
static DateTime? parseDate(String dateString, String format) {
try {
return DateFormat(format).parse(dateString);
} catch (e) {
return null;
}
}
/// Internal formatter with default format.
static String _formatDateTime(DateTime dateTime,
{String format = 'dd-MM-yyyy'}) {
static String _formatDateTime(DateTime dateTime, {String format = 'dd-MM-yyyy'}) {
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 {
// ------------------- Project Management ------------------------------
/// Permission to manage master data (like dropdowns, configurations)
static const String manageMaster = "588a8824-f924-4955-82d8-fc51956cf323";
/// Permission to create, edit, delete projects
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";
/// 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";
/// Permission to view all employees
static const String viewAllEmployees = "60611762-7f8a-4fb5-b53f-b1139918796b";
/// Permission to view only team members (subordinate employees)
static const String viewTeamMembers = "b82d2b7e-0d52-45f3-997b-c008ea460e7f";
// ------------------- Project Infrastructure --------------------------
/// Permission to manage project infrastructure (e.g., site details)
static const String manageProjectInfra = "cf2825ad-453b-46aa-91d9-27c124d63373";
/// Permission to view infrastructure-related details
static const String viewProjectInfra = "8d7cc6e3-9147-41f7-aaa7-fa507e450bd4";
// ------------------- Attendance Management ---------------------------
/// Permission to regularize (edit/update) attendance records
static const String regularizeAttendance = "57802c4a-00aa-4a1f-a048-fd2f70dd44b6";
// ------------------- Task Management ---------------------------------
/// Permission to create and manage tasks
static const String manageProjectInfra ="f2aee20a-b754-4537-8166-f9507b44585b";
static const String viewProjectInfra = "c7b68e33-72f0-474f-bd96-77636427ecc8";
static const String regularizeAttendance ="57802c4a-00aa-4a1f-a048-fd2f70dd44b6";
static const String assignToProject = "fbd213e0-0250-46f1-9f5f-4b2a1e6e76a3";
static const String infrastructure = "9666de86-d7c7-4d3d-acaa-fcd6d6b81f3c";
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";
/// Permission to assign tasks for reporting
static const String assignReportTask = "6a32379b-8b3f-49a6-8c48-4b7ac1b55dc2";
// ------------------- Directory Roles ---------------------------------
/// Admin-level directory access
static const String directoryAdmin = "4286a13b-bb40-4879-8c6d-18e9e393beda";
/// Manager-level directory access
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_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 VoidCallback onCancel;
final Future<void> Function(quill.QuillController controller) onSave;
@ -14,31 +13,13 @@ class CommentEditorCard extends StatefulWidget {
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
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
quill.QuillSimpleToolbar(
controller: widget.controller,
controller: controller,
configurations: const quill.QuillSimpleToolbarConfigurations(
showBoldButton: true,
showItalicButton: true,
@ -67,7 +48,7 @@ class _CommentEditorCardState extends State<CommentEditorCard> {
multiRowsDisplay: false,
),
),
const SizedBox(height: 24),
const SizedBox(height: 38),
Container(
height: 140,
padding: const EdgeInsets.all(8),
@ -77,7 +58,7 @@ class _CommentEditorCardState extends State<CommentEditorCard> {
color: const Color(0xFFFDFDFD),
),
child: quill.QuillEditor.basic(
controller: widget.controller,
controller: controller,
configurations: const quill.QuillEditorConfigurations(
autoFocus: true,
expands: false,
@ -85,50 +66,32 @@ class _CommentEditorCardState extends State<CommentEditorCard> {
),
),
),
const SizedBox(height: 16),
// 👇 Buttons same as BaseBottomSheet
Row(
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isSubmitting ? null : widget.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),
OutlinedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.close, size: 18),
label: const Text("Cancel"),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.grey[700],
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: _isSubmitting ? null : _handleSave,
icon: const Icon(Icons.check_circle_outline, color: Colors.white),
label: MyText.bodyMedium(
_isSubmitting ? "Submitting..." : "Submit",
color: Colors.white,
fontWeight: 600,
),
ElevatedButton.icon(
onPressed: () => onSave(controller),
icon: const Icon(Icons.save, size: 18),
label: const Text("Save"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
foregroundColor: Colors.white,
),
),
],
),
)
],
);
}

View File

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

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,
margin: const EdgeInsets.all(16),
borderRadius: 8,
duration: const Duration(seconds: 5),
duration: const Duration(seconds: 3),
icon: Icon(
iconData,
color: Colors.white,

View File

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

View File

@ -1,9 +1,7 @@
import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/my_text.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/helpers/utils/base_bottom_sheet.dart';
class TeamMembersBottomSheet {
static void show(
@ -13,9 +11,8 @@ class TeamMembersBottomSheet {
bool canEdit = false,
VoidCallback? onEdit,
}) {
// Ensure the owner is at the top of the list
final ownerId = bucket.createdBy.id;
// Ensure owner is listed first
members.sort((a, b) {
if (a.id == ownerId) return -1;
if (b.id == ownerId) return 1;
@ -26,63 +23,51 @@ class TeamMembersBottomSheet {
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) {
return BaseBottomSheet(
title: 'Bucket Details',
onCancel: () => Navigator.pop(context),
onSubmit: () {}, // Not used, but required
showButtons: false,
child: _TeamContent(
bucket: bucket,
members: members,
canEdit: canEdit,
onEdit: onEdit,
ownerId: ownerId,
isDismissible: true,
enableDrag: true,
builder: (context) {
return SafeArea(
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
);
},
);
}
}
class _TeamContent extends StatelessWidget {
final ContactBucket bucket;
final List<dynamic> members;
final bool canEdit;
final VoidCallback? onEdit;
final String ownerId;
const _TeamContent({
required this.bucket,
required this.members,
required this.canEdit,
this.onEdit,
required this.ownerId,
});
@override
Widget build(BuildContext context) {
child: DraggableScrollableSheet(
expand: false,
initialChildSize: 0.7,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (context, scrollController) {
return Column(
children: [
_buildHeader(),
_buildInfo(),
_buildMembersTitle(),
MySpacing.height(8),
SizedBox(
height: 300,
child: _buildMemberList(),
const SizedBox(height: 6),
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
],
);
}
),
const SizedBox(height: 10),
Widget _buildHeader() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
MyText.titleMedium(
'Bucket Details',
fontWeight: 700,
),
const SizedBox(height: 12),
// Header with title and edit
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: MyText.titleMedium(bucket.name, fontWeight: 700),
child: MyText.titleMedium(
bucket.name,
fontWeight: 700,
),
),
if (canEdit)
IconButton(
@ -92,12 +77,11 @@ class _TeamContent extends StatelessWidget {
),
],
),
);
}
),
Widget _buildInfo() {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
// Info
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -111,7 +95,8 @@ class _TeamContent extends StatelessWidget {
),
Row(
children: [
const Icon(Icons.contacts_outlined, size: 14, color: Colors.grey),
const Icon(Icons.contacts_outlined,
size: 14, color: Colors.grey),
const SizedBox(width: 4),
MyText.labelSmall(
'${bucket.numberOfContacts} contact(s)',
@ -119,7 +104,8 @@ class _TeamContent extends StatelessWidget {
color: Colors.red,
),
const SizedBox(width: 12),
const Icon(Icons.ios_share_outlined, size: 14, color: Colors.grey),
const Icon(Icons.ios_share_outlined,
size: 14, color: Colors.grey),
const SizedBox(width: 4),
MyText.labelSmall(
'Shared with (${members.length})',
@ -128,56 +114,68 @@ class _TeamContent extends StatelessWidget {
),
],
),
MySpacing.height(8),
Row(
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
const Icon(Icons.edit_outlined, size: 14, color: Colors.grey),
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',
canEdit
? 'Can be edited by you'
: 'You dont have edit access',
fontWeight: 600,
color: canEdit ? Colors.green : Colors.grey,
),
],
),
MySpacing.height(12),
),
const SizedBox(height: 8),
const Divider(thickness: 1),
const SizedBox(height: 6),
MyText.labelLarge(
'Shared with',
fontWeight: 700,
color: Colors.black,
),
],
),
);
}
),
Widget _buildMembersTitle() {
return Align(
alignment: Alignment.centerLeft,
child: MyText.labelLarge('Shared with', fontWeight: 700, color: Colors.black),
);
}
const SizedBox(height: 4),
Widget _buildMemberList() {
if (members.isEmpty) {
return Center(
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: members.isEmpty
? Center(
child: MyText.bodySmall(
"No team members found.",
fontWeight: 600,
color: Colors.grey,
),
);
}
return ListView.separated(
)
: ListView.separated(
controller: scrollController,
itemCount: members.length,
separatorBuilder: (_, __) => const SizedBox(height: 6),
separatorBuilder: (_, __) =>
const SizedBox(height: 4),
itemBuilder: (context, index) {
final member = members[index];
final firstName = member.firstName ?? '';
final lastName = member.lastName ?? '';
final isOwner = member.id == ownerId;
final isOwner =
member.id == bucket.createdBy.id;
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Avatar(firstName: firstName, lastName: lastName, size: 32),
leading: Avatar(
firstName: firstName,
lastName: lastName,
size: 32,
),
title: Row(
children: [
Expanded(
@ -188,11 +186,14 @@ class _TeamContent extends StatelessWidget {
),
if (isOwner)
Container(
margin: const EdgeInsets.only(left: 6),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
margin:
const EdgeInsets.only(left: 6),
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(4),
borderRadius:
BorderRadius.circular(4),
),
child: MyText.labelSmall(
"Owner",
@ -208,6 +209,18 @@ class _TeamContent extends StatelessWidget {
),
);
},
),
),
),
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,50 +1,35 @@
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/view/my_app.dart';
import 'package:provider/provider.dart';
import 'package:marco/helpers/theme/app_notifier.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 {
WidgetsFlutterBinding.ensureInitialized();
// Initialize logging system
await initLogging();
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 {
await initializeApp();
logSafe("App initialized successfully.");
runApp(
ChangeNotifierProvider(
ChangeNotifierProvider<AppNotifier>(
create: (_) => AppNotifier(),
child: const MainWrapper(),
child: const MyApp(),
),
);
} catch (e, stacktrace) {
logSafe(
'App failed to initialize.',
logSafe('App failed to initialize.',
level: LogLevel.error,
error: e,
stackTrace: stacktrace,
);
runApp(_buildErrorApp());
}
}
Widget _buildErrorApp() => const MaterialApp(
runApp(
const MaterialApp(
home: Scaffold(
body: Center(
child: Text(
@ -53,40 +38,7 @@ Widget _buildErrorApp() => const MaterialApp(
),
),
),
),
);
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,365 +1,59 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.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/controller/project_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class AttendanceActionButton extends StatefulWidget {
final dynamic employee;
final AttendanceController attendanceController;
const AttendanceActionButton({
super.key,
Key? key,
required this.employee,
required this.attendanceController,
});
}) : super(key: key);
@override
State<AttendanceActionButton> createState() => _AttendanceActionButtonState();
}
class _AttendanceActionButtonState extends State<AttendanceActionButton> {
late final String uniqueLogKey;
@override
void initState() {
super.initState();
uniqueLogKey = AttendanceButtonHelper.getUniqueKey(
widget.employee.employeeId,
widget.employee.id,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.attendanceController.uploadingStates.putIfAbsent(
uniqueLogKey,
() => false.obs,
);
});
}
Future<DateTime?> _pickRegularizationTime(DateTime checkInTime) async {
final pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(DateTime.now()),
);
if (pickedTime == null) return null;
final selected = DateTime(
checkInTime.year,
checkInTime.month,
checkInTime.day,
pickedTime.hour,
pickedTime.minute,
);
final now = DateTime.now();
if (selected.isBefore(checkInTime)) {
showAppSnackbar(
title: "Invalid Time",
message: "Time must be after check-in.",
type: SnackbarType.warning,
);
return null;
}
if (selected.isAfter(now)) {
showAppSnackbar(
title: "Invalid Time",
message: "Future time is not allowed.",
type: SnackbarType.warning,
);
return null;
}
return selected;
}
Future<void> _handleButtonPressed() async {
final controller = widget.attendanceController;
final projectController = Get.find<ProjectController>();
final selectedProjectId = projectController.selectedProject?.id;
if (selectedProjectId == null) {
showAppSnackbar(
title: "Project Required",
message: "Please select a project first",
type: SnackbarType.error,
);
return;
}
controller.uploadingStates[uniqueLogKey]?.value = true;
int action;
String actionText;
bool imageCapture = true;
switch (widget.employee.activity) {
case 0:
case 4:
action = 0;
actionText = ButtonActions.checkIn;
break;
case 1:
final isOldCheckIn =
AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2);
final isOldCheckOut =
AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2);
if (widget.employee.checkOut == null && isOldCheckIn) {
action = 2;
actionText = ButtonActions.requestRegularize;
imageCapture = false;
} else if (widget.employee.checkOut != null && isOldCheckOut) {
action = 2;
actionText = ButtonActions.requestRegularize;
} else {
action = 1;
actionText = ButtonActions.checkOut;
}
break;
case 2:
action = 2;
actionText = ButtonActions.requestRegularize;
break;
default:
action = 0;
actionText = "Unknown Action";
break;
}
DateTime? selectedTime;
final isYesterdayCheckIn = widget.employee.checkIn != null &&
DateUtils.isSameDay(
widget.employee.checkIn,
DateTime.now().subtract(const Duration(days: 1)),
);
if (isYesterdayCheckIn &&
widget.employee.checkOut == null &&
actionText == ButtonActions.checkOut) {
selectedTime = await _pickRegularizationTime(widget.employee.checkIn!);
if (selectedTime == null) {
controller.uploadingStates[uniqueLogKey]?.value = false;
return;
}
}
final comment = await _showCommentBottomSheet(
context,
actionText,
selectedTime: selectedTime,
checkInDate: widget.employee.checkIn,
);
if (comment == null || comment.isEmpty) {
controller.uploadingStates[uniqueLogKey]?.value = false;
return;
}
String? markTime;
if (actionText == ButtonActions.requestRegularize) {
selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!);
markTime = selectedTime != null
? DateFormat("hh:mm a").format(selectedTime)
: null;
} else if (selectedTime != null) {
markTime = DateFormat("hh:mm a").format(selectedTime);
}
final success = await controller.captureAndUploadAttendance(
widget.employee.id,
widget.employee.employeeId,
selectedProjectId,
comment: comment,
action: action,
imageCapture: imageCapture,
markTime: markTime,
);
showAppSnackbar(
title: success ? '${capitalizeFirstLetter(actionText)} Success' : 'Error',
message: success
? '${capitalizeFirstLetter(actionText)} marked successfully!'
: 'Failed to ${actionText.toLowerCase()}',
type: success ? SnackbarType.success : SnackbarType.error,
);
controller.uploadingStates[uniqueLogKey]?.value = false;
if (success) {
await controller.fetchTodaysAttendance(selectedProjectId);
await controller.fetchAttendanceLogs(selectedProjectId);
await controller.fetchRegularizationLogs(selectedProjectId);
await controller.fetchProjectData(selectedProjectId);
controller.update();
}
}
@override
Widget build(BuildContext context) {
return Obx(() {
final controller = widget.attendanceController;
final isUploading =
controller.uploadingStates[uniqueLogKey]?.value ?? false;
final emp = widget.employee;
final isYesterday =
AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut);
final isTodayApproved =
AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn);
final isApprovedButNotToday =
AttendanceButtonHelper.isApprovedButNotToday(
emp.activity, isTodayApproved);
final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled(
isUploading: isUploading,
isYesterday: isYesterday,
activity: emp.activity,
isApprovedButNotToday: isApprovedButNotToday,
);
final buttonText = AttendanceButtonHelper.getButtonText(
activity: emp.activity,
checkIn: emp.checkIn,
checkOut: emp.checkOut,
isTodayApproved: isTodayApproved,
);
final buttonColor = AttendanceButtonHelper.getButtonColor(
isYesterday: isYesterday,
isTodayApproved: isTodayApproved,
activity: emp.activity,
);
return AttendanceActionButtonUI(
isUploading: isUploading,
isButtonDisabled: isButtonDisabled,
buttonText: buttonText,
buttonColor: buttonColor,
onPressed: isButtonDisabled ? null : _handleButtonPressed,
);
});
}
}
class AttendanceActionButtonUI extends StatelessWidget {
final bool isUploading;
final bool isButtonDisabled;
final String buttonText;
final Color buttonColor;
final VoidCallback? onPressed;
const AttendanceActionButtonUI({
super.key,
required this.isUploading,
required this.isButtonDisabled,
required this.buttonText,
required this.buttonColor,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 30,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: buttonColor,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
textStyle: const TextStyle(fontSize: 12),
),
child: isUploading
? Container(
width: 60,
height: 14,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (buttonText.toLowerCase() == 'approved')
const Icon(Icons.check, size: 16, color: Colors.green),
if (buttonText.toLowerCase() == 'rejected')
const Icon(Icons.close, size: 16, color: Colors.red),
if (buttonText.toLowerCase() == 'requested')
const Icon(Icons.hourglass_top,
size: 16, color: Colors.orange),
if (['approved', 'rejected', 'requested']
.contains(buttonText.toLowerCase()))
const SizedBox(width: 4),
Flexible(
child: Text(
buttonText,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
),
],
),
),
);
}
}
Future<String?> _showCommentBottomSheet(
BuildContext context,
String actionText, {
DateTime? selectedTime,
DateTime? checkInDate,
}) async {
final commentController = TextEditingController();
BuildContext context, String actionText) async {
final TextEditingController 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)}";
}
Get.find<ProjectController>().selectedProject?.id;
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
backgroundColor: Colors.white,
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(
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(
@ -377,6 +71,34 @@ Future<String?> _showCommentBottomSheet(
}
},
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () {
final comment = commentController.text.trim();
if (comment.isEmpty) {
setModalState(() {
errorText = 'Comment cannot be empty.';
});
return;
}
Navigator.of(context).pop(comment);
},
child: const Text('Submit'),
),
),
],
),
],
),
);
},
@ -385,5 +107,325 @@ Future<String?> _showCommentBottomSheet(
);
}
String capitalizeFirstLetter(String text) =>
text.isEmpty ? text : text[0].toUpperCase() + text.substring(1);
String capitalizeFirstLetter(String text) {
if (text.isEmpty) return text;
return text[0].toUpperCase() + text.substring(1);
}
class _AttendanceActionButtonState extends State<AttendanceActionButton> {
late final String uniqueLogKey;
@override
void initState() {
super.initState();
uniqueLogKey = AttendanceButtonHelper.getUniqueKey(
widget.employee.employeeId, widget.employee.id);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!widget.attendanceController.uploadingStates
.containsKey(uniqueLogKey)) {
widget.attendanceController.uploadingStates[uniqueLogKey] = false.obs;
}
});
}
Future<DateTime?> showTimePickerForRegularization({
required BuildContext context,
required DateTime checkInTime,
}) async {
final pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(DateTime.now()),
);
if (pickedTime != null) {
final selectedDateTime = DateTime(
checkInTime.year,
checkInTime.month,
checkInTime.day,
pickedTime.hour,
pickedTime.minute,
);
final now = DateTime.now();
if (selectedDateTime.isBefore(checkInTime)) {
showAppSnackbar(
title: "Invalid Time",
message: "Time must be after check-in.",
type: SnackbarType.warning,
);
return null;
} else if (selectedDateTime.isAfter(now)) {
showAppSnackbar(
title: "Invalid Time",
message: "Future time is not allowed.",
type: SnackbarType.warning,
);
return null;
}
return selectedDateTime;
}
return null;
}
void _handleButtonPressed(BuildContext context) async {
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = true;
final projectController = Get.find<ProjectController>();
final selectedProjectId = projectController.selectedProject?.id;
if (selectedProjectId == null) {
showAppSnackbar(
title: "Project Required",
message: "Please select a project first",
type: SnackbarType.error,
);
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false;
return;
}
int updatedAction;
String actionText;
bool imageCapture = true;
switch (widget.employee.activity) {
case 0:
updatedAction = 0;
actionText = ButtonActions.checkIn;
break;
case 1:
if (widget.employee.checkOut == null &&
AttendanceButtonHelper.isOlderThanDays(
widget.employee.checkIn, 2)) {
updatedAction = 2;
actionText = ButtonActions.requestRegularize;
imageCapture = false;
} else if (widget.employee.checkOut != null &&
AttendanceButtonHelper.isOlderThanDays(
widget.employee.checkOut, 2)) {
updatedAction = 2;
actionText = ButtonActions.requestRegularize;
} else {
updatedAction = 1;
actionText = ButtonActions.checkOut;
}
break;
case 2:
updatedAction = 2;
actionText = ButtonActions.requestRegularize;
break;
case 4:
updatedAction = 0;
actionText = ButtonActions.checkIn;
break;
default:
updatedAction = 0;
actionText = "Unknown Action";
break;
}
DateTime? selectedTime;
// New condition: Yesterday Check-In + CheckOut action
final isYesterdayCheckIn = widget.employee.checkIn != null &&
DateUtils.isSameDay(
widget.employee.checkIn,
DateTime.now().subtract(const Duration(days: 1)),
);
if (isYesterdayCheckIn &&
widget.employee.checkOut == null &&
actionText == ButtonActions.checkOut) {
selectedTime = await showTimePickerForRegularization(
context: context,
checkInTime: widget.employee.checkIn!,
);
if (selectedTime == null) {
widget.attendanceController.uploadingStates[uniqueLogKey]?.value =
false;
return;
}
}
final userComment = await _showCommentBottomSheet(context, actionText);
if (userComment == null || userComment.isEmpty) {
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false;
return;
}
bool success = false;
if (actionText == ButtonActions.requestRegularize) {
final regularizeTime = selectedTime ??
await showTimePickerForRegularization(
context: context,
checkInTime: widget.employee.checkIn!,
);
if (regularizeTime != null) {
final formattedSelectedTime =
DateFormat("hh:mm a").format(regularizeTime);
success = await widget.attendanceController.captureAndUploadAttendance(
widget.employee.id,
widget.employee.employeeId,
selectedProjectId,
comment: userComment,
action: updatedAction,
imageCapture: imageCapture,
markTime: formattedSelectedTime,
);
}
} else if (selectedTime != null) {
// If selectedTime was picked in the new condition
final formattedSelectedTime = DateFormat("hh:mm a").format(selectedTime);
success = await widget.attendanceController.captureAndUploadAttendance(
widget.employee.id,
widget.employee.employeeId,
selectedProjectId,
comment: userComment,
action: updatedAction,
imageCapture: imageCapture,
markTime: formattedSelectedTime,
);
} else {
success = await widget.attendanceController.captureAndUploadAttendance(
widget.employee.id,
widget.employee.employeeId,
selectedProjectId,
comment: userComment,
action: updatedAction,
imageCapture: imageCapture,
);
}
showAppSnackbar(
title: success ? '${capitalizeFirstLetter(actionText)} Success' : 'Error',
message: success
? '${capitalizeFirstLetter(actionText)} marked successfully!'
: 'Failed to ${actionText.toLowerCase()}',
type: success ? SnackbarType.success : SnackbarType.error,
);
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false;
if (success) {
widget.attendanceController.fetchEmployeesByProject(selectedProjectId);
widget.attendanceController.fetchAttendanceLogs(selectedProjectId);
await widget.attendanceController
.fetchRegularizationLogs(selectedProjectId);
await widget.attendanceController.fetchProjectData(selectedProjectId);
widget.attendanceController.update();
}
}
@override
Widget build(BuildContext context) {
return Obx(() {
final isUploading =
widget.attendanceController.uploadingStates[uniqueLogKey]?.value ??
false;
final isYesterday = AttendanceButtonHelper.isLogFromYesterday(
widget.employee.checkIn, widget.employee.checkOut);
final isTodayApproved = AttendanceButtonHelper.isTodayApproved(
widget.employee.activity, widget.employee.checkIn);
final isApprovedButNotToday =
AttendanceButtonHelper.isApprovedButNotToday(
widget.employee.activity, isTodayApproved);
final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled(
isUploading: isUploading,
isYesterday: isYesterday,
activity: widget.employee.activity,
isApprovedButNotToday: isApprovedButNotToday,
);
final buttonText = AttendanceButtonHelper.getButtonText(
activity: widget.employee.activity,
checkIn: widget.employee.checkIn,
checkOut: widget.employee.checkOut,
isTodayApproved: isTodayApproved,
);
final buttonColor = AttendanceButtonHelper.getButtonColor(
isYesterday: isYesterday,
isTodayApproved: isTodayApproved,
activity: widget.employee.activity,
);
return AttendanceActionButtonUI(
isUploading: isUploading,
isButtonDisabled: isButtonDisabled,
buttonText: buttonText,
buttonColor: buttonColor,
onPressed:
isButtonDisabled ? null : () => _handleButtonPressed(context),
);
});
}
}
class AttendanceActionButtonUI extends StatelessWidget {
final bool isUploading;
final bool isButtonDisabled;
final String buttonText;
final Color buttonColor;
final VoidCallback? onPressed;
const AttendanceActionButtonUI({
Key? key,
required this.isUploading,
required this.isButtonDisabled,
required this.buttonText,
required this.buttonColor,
required this.onPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
height: 30,
child: ElevatedButton(
onPressed: isButtonDisabled ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: buttonColor,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
textStyle: const TextStyle(fontSize: 12),
),
child: isUploading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (buttonText.toLowerCase() == 'approved') ...[
const Icon(Icons.check, size: 16, color: Colors.green),
const SizedBox(width: 4),
] else if (buttonText.toLowerCase() == 'rejected') ...[
const Icon(Icons.close, size: 16, color: Colors.red),
const SizedBox(width: 4),
] else if (buttonText.toLowerCase() == 'requested') ...[
const Icon(Icons.hourglass_top,
size: 16, color: Colors.orange),
const SizedBox(width: 4),
],
Flexible(
child: Text(
buttonText,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
),
],
),
),
);
}
}

View File

@ -1,11 +1,9 @@
import 'package:flutter/material.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/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 {
final AttendanceController controller;
@ -20,7 +18,7 @@ class AttendanceFilterBottomSheet extends StatefulWidget {
});
@override
State<AttendanceFilterBottomSheet> createState() =>
_AttendanceFilterBottomSheetState createState() =>
_AttendanceFilterBottomSheetState();
}
@ -37,79 +35,14 @@ class _AttendanceFilterBottomSheetState
String getLabelText() {
final startDate = widget.controller.startDateAttendance;
final endDate = widget.controller.endDateAttendance;
if (startDate != null && endDate != null) {
final start = DateTimeUtils.formatDate(startDate, 'dd MMM yyyy');
final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy');
final start = DateFormat('dd/MM/yyyy').format(startDate);
final end = DateFormat('dd/MM/yyyy').format(endDate);
return "$start - $end";
}
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() {
final hasRegularizationPermission = widget.permissionController
.hasPermission(Permissions.regularizeAttendance);
@ -120,124 +53,78 @@ class _AttendanceFilterBottomSheetState
{'label': 'Regularization Requests', 'value': 'regularizationRequests'},
];
final filteredOptions = viewOptions.where((item) {
return item['value'] != 'regularizationRequests' ||
hasRegularizationPermission;
final filteredViewOptions = viewOptions.where((item) {
if (item['value'] == 'regularizationRequests') {
return hasRegularizationPermission;
}
return true;
}).toList();
final List<Widget> widgets = [
List<Widget> widgets = [
Padding(
padding: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Align(
alignment: Alignment.centerLeft,
child: MyText.titleSmall("View", fontWeight: 600),
child: MyText.titleSmall(
"View",
fontWeight: 600,
),
),
...filteredOptions.map((item) {
),
...filteredViewOptions.map((item) {
return RadioListTile<String>(
dense: true,
contentPadding: EdgeInsets.zero,
title: MyText.bodyMedium(
item['label']!,
fontWeight: 500,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
title: Text(item['label']!),
value: item['value']!,
groupValue: tempSelectedTab,
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') {
widgets.addAll([
const Divider(),
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 4),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Align(
alignment: Alignment.centerLeft,
child: MyText.titleSmall("Date Range", fontWeight: 600),
child: MyText.titleSmall(
"Date Range",
fontWeight: 600,
),
),
InkWell(
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () async {
await widget.controller.selectDateRangeForAttendance(
onTap: () => widget.controller.selectDateRangeForAttendance(
context,
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),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
const Icon(Icons.date_range, color: Colors.black87),
Icon(Icons.date_range, color: Colors.black87),
const SizedBox(width: 12),
Expanded(
child: MyText.bodyMedium(
child: Text(
getLabelText(),
fontWeight: 500,
style: const TextStyle(
fontSize: 16,
color: Colors.black87,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.black87),
@ -245,6 +132,7 @@ class _AttendanceFilterBottomSheetState
),
),
),
),
]);
}
@ -253,19 +141,49 @@ class _AttendanceFilterBottomSheetState
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: BaseBottomSheet(
title: "Attendance Filter",
submitText: "Apply",
onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context, {
'selectedTab': tempSelectedTab,
'selectedOrganization': widget.controller.selectedOrganization?.id,
}),
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: buildMainFilters(),
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(4),
),
),
),
),
...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:url_launcher/url_launcher.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/utils/attendance_actions.dart';
class AttendanceLogViewButton extends StatefulWidget {
class AttendanceLogViewButton extends StatelessWidget {
final dynamic employee;
final dynamic attendanceController;
final dynamic attendanceController; // Use correct types as needed
const AttendanceLogViewButton({
Key? key,
@ -14,12 +13,6 @@ class AttendanceLogViewButton extends StatefulWidget {
required this.attendanceController,
}) : super(key: key);
@override
State<AttendanceLogViewButton> createState() =>
_AttendanceLogViewButtonState();
}
class _AttendanceLogViewButtonState extends State<AttendanceLogViewButton> {
Future<void> _openGoogleMaps(
BuildContext context, double lat, double lon) async {
final url = 'https://www.google.com/maps/search/?api=1&query=$lat,$lon';
@ -56,47 +49,59 @@ class _AttendanceLogViewButtonState extends State<AttendanceLogViewButton> {
}
void _showLogsBottomSheet(BuildContext context) async {
await widget.attendanceController
.fetchLogsView(widget.employee.id.toString());
await attendanceController.fetchLogsView(employee.id.toString());
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
backgroundColor: Colors.transparent,
builder: (context) {
Map<int, bool> expandedDescription = {};
return BaseBottomSheet(
title: "Attendance Log",
onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context),
showButtons: false,
child: widget.attendanceController.attendenceLogsView.isEmpty
? Padding(
backgroundColor: Theme.of(context).cardColor,
builder: (context) => Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: SingleChildScrollView(
child: Column(
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),
child: Column(
children: [
children: const [
Icon(Icons.info_outline, size: 40, color: Colors.grey),
SizedBox(height: 8),
MyText.bodySmall("No attendance logs available."),
Text("No attendance logs available."),
],
),
)
: StatefulBuilder(
builder: (context, setStateSB) {
return ListView.separated(
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount:
widget.attendanceController.attendenceLogsView.length,
itemCount: attendanceController.attendenceLogsView.length,
separatorBuilder: (_, __) => const SizedBox(height: 16),
itemBuilder: (_, index) {
final log = widget
.attendanceController.attendenceLogsView[index];
final log = attendanceController.attendenceLogsView[index];
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
@ -109,41 +114,91 @@ class _AttendanceLogViewButtonState extends State<AttendanceLogViewButton> {
)
],
),
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: Icon + Date + Time
Row(
children: [
_getLogIcon(log),
const SizedBox(width: 12),
const SizedBox(width: 10),
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
MyText.bodyLarge(
(log.formattedDate != null &&
log.formattedDate!.isNotEmpty)
? DateTimeUtils.convertUtcToLocal(
log.formattedDate!,
format: 'd MMM yyyy',
)
: '-',
log.formattedDate ?? '-',
fontWeight: 600,
),
const SizedBox(width: 12),
MyText.bodySmall(
log.formattedTime != null
? "Time: ${log.formattedTime}"
: "",
"Time: ${log.formattedTime ?? '-'}",
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,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
// Image Column
if (log.latitude != null &&
log.longitude != null)
GestureDetector(
onTap: () {
final lat = double.tryParse(log
.latitude
.toString()) ??
0.0;
final lon = double.tryParse(log
.longitude
.toString()) ??
0.0;
if (lat >= -90 &&
lat <= 90 &&
lon >= -180 &&
lon <= 180) {
_openGoogleMaps(
context, lat, lon);
} else {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Invalid location coordinates')),
);
}
},
child: const Padding(
padding:
EdgeInsets.only(right: 8.0),
child: Icon(Icons.location_on,
size: 18, color: Colors.blue),
),
),
Expanded(
child: MyText.bodyMedium(
log.comment?.isNotEmpty == true
? log.comment
: "No description provided",
fontWeight: 500,
),
),
],
),
],
),
),
const SizedBox(width: 16),
if (log.thumbPreSignedUrl != null)
GestureDetector(
onTap: () {
@ -159,145 +214,28 @@ class _AttendanceLogViewButtonState extends State<AttendanceLogViewButton> {
height: 60,
width: 60,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const Icon(Icons.broken_image,
size: 40, color: Colors.grey),
),
),
),
if (log.thumbPreSignedUrl != null)
const SizedBox(width: 12),
// Text Column
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
// Done by
if (log.updatedByEmployee != null)
MyText.bodySmall(
"By: ${log.updatedByEmployee!.firstName} ${log.updatedByEmployee!.lastName}",
color: Colors.grey[700],
),
const SizedBox(height: 8),
// Location
if (log.latitude != null &&
log.longitude != null)
Row(
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: () {
final lat = double.tryParse(
log.latitude
.toString()) ??
0.0;
final lon = double.tryParse(
log.longitude
.toString()) ??
0.0;
if (lat >= -90 &&
lat <= 90 &&
lon >= -180 &&
lon <= 180) {
_openGoogleMaps(
context, lat, lon);
} else {
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: MyText.bodySmall(
"Invalid location coordinates")),
);
}
errorBuilder:
(context, error, stackTrace) {
return const Icon(Icons.broken_image,
size: 20, color: Colors.grey);
},
child: Row(
children: [
Icon(Icons.location_on,
size: 16,
color: Colors.blue),
SizedBox(width: 4),
MyText.bodySmall(
"View Location",
color: Colors.blue,
decoration:
TextDecoration.underline,
),
],
),
),
],
),
const SizedBox(height: 8),
// Description with label and more/less using MyText
if (log.comment != null &&
log.comment!.isNotEmpty)
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
MyText.bodySmall(
"Description: ${log.comment!}",
maxLines: expandedDescription[
index] ==
true
? null
: 2,
overflow: expandedDescription[
index] ==
true
? TextOverflow.visible
: TextOverflow.ellipsis,
),
if (log.comment!.length > 100)
GestureDetector(
onTap: () {
setStateSB(() {
expandedDescription[
index] =
!(expandedDescription[
index] ==
true);
});
},
child: MyText.bodySmall(
expandedDescription[
index] ==
true
? "less"
: "more",
color: Colors.blue,
fontWeight: 600,
),
),
],
)
else
MyText.bodySmall(
"Description: No description provided",
fontWeight: 700,
),
],
),
),
const Icon(Icons.broken_image,
size: 20, color: Colors.grey),
],
),
],
),
);
},
);
},
)
],
),
),
),
);
},
);
}
@ -308,16 +246,16 @@ class _AttendanceLogViewButtonState extends State<AttendanceLogViewButton> {
child: ElevatedButton(
onPressed: () => _showLogsBottomSheet(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
backgroundColor: AttendanceActionColors.colors[ButtonActions.checkIn],
textStyle: const TextStyle(fontSize: 12),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
child: FittedBox(
child: const FittedBox(
fit: BoxFit.scaleDown,
child: MyText.bodySmall(
child: Text(
"View",
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 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);
}

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,11 +3,11 @@ import 'package:marco/helpers/utils/attendance_actions.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:get/get.dart';
enum ButtonActions { approve, reject }
class RegularizeActionButton extends StatefulWidget {
final dynamic attendanceController;
final dynamic
attendanceController;
final dynamic log;
final String uniqueLogKey;
final ButtonActions action;
@ -70,11 +70,9 @@ class _RegularizeActionButtonState extends State<RegularizeActionButton> {
isUploading = true;
});
widget.attendanceController.uploadingStates[widget.uniqueLogKey]?.value =
true;
widget.attendanceController.uploadingStates[widget.uniqueLogKey]?.value = true;
final success =
await widget.attendanceController.captureAndUploadAttendance(
final success = await widget.attendanceController.captureAndUploadAttendance(
widget.log.id,
widget.log.employeeId,
selectedProjectId,
@ -94,18 +92,17 @@ class _RegularizeActionButtonState extends State<RegularizeActionButton> {
if (success) {
widget.attendanceController.fetchEmployeesByProject(selectedProjectId);
widget.attendanceController.fetchAttendanceLogs(selectedProjectId);
await widget.attendanceController
.fetchRegularizationLogs(selectedProjectId);
await widget.attendanceController.fetchRegularizationLogs(selectedProjectId);
await widget.attendanceController.fetchProjectData(selectedProjectId);
}
widget.attendanceController.uploadingStates[widget.uniqueLogKey]?.value =
false;
widget.attendanceController.uploadingStates[widget.uniqueLogKey]?.value = false;
setState(() {
isUploading = false;
});
}
}
@override
Widget build(BuildContext context) {
@ -119,19 +116,17 @@ class _RegularizeActionButtonState extends State<RegularizeActionButton> {
onPressed: isUploading ? null : _handlePress,
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor,
foregroundColor: Colors.white,
foregroundColor:
Colors.white, // Ensures visibility on all backgrounds
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 6),
minimumSize: const Size(60, 20),
textStyle: const TextStyle(fontSize: 12),
),
child: isUploading
? Container(
width: 60,
height: 14,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: FittedBox(
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