Compare commits

...

58 Commits

Author SHA1 Message Date
ef4329530a ... 2025-11-10 12:13:17 +05:30
fd5ea9a1b3 edit payment request 2025-11-10 12:10:04 +05:30
546084c9f3 added skeleton for the payment request screen 2025-11-07 18:13:23 +05:30
44674da8ac added process flow and make payment functionallity 2025-11-07 17:19:56 +05:30
a2cf65fb86 chnages the size of the payee and the date 2025-11-06 17:56:15 +05:30
f55cf343fb added code for payement request and payment request details screen 2025-11-06 17:44:18 +05:30
1a6ad4edfc made chnages in request payment 2025-11-06 12:21:53 +05:30
42c2739d0c added fab icon 2025-11-05 17:37:32 +05:30
0954e46c61 Merge branch 'PR_Dev_Vaibhav' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into PR_Dev_Vaibhav 2025-11-05 17:30:38 +05:30
350081f6b6 added payment request code 2025-11-05 17:30:16 +05:30
ac01ee8e47 added payment request code 2025-11-05 17:30:07 +05:30
0b0c072473 updates finance screen 2025-11-05 17:26:32 +05:30
a2f5414240 Merge pull request 'feat: Enhance FinanceScreen with animation and improved UI components' (#80) from PR_Dev_Vaibhav into Feature_PR
Reviewed-on: #80
2025-11-05 09:53:33 +00:00
b857c4d8bc feat: Enhance FinanceScreen with animation and improved UI components 2025-11-05 15:21:00 +05:30
1981c90138 Merge pull request 'PR_Dev_Vaibhav' (#79) from PR_Dev_Vaibhav into Feature_PR
Reviewed-on: #79
2025-11-05 09:49:43 +00:00
e7a8a844d1 fix: Update navigation route in ExpenseAppBar to point to finance dashboard 2025-11-05 15:18:46 +05:30
028f17dddd feat: Add FinanceScreen and update routes for finance dashboard 2025-11-05 12:33:15 +05:30
41ab77d136 refactor: Enhance employee sorting by action priority and latest entry time 2025-11-04 15:47:15 +05:30
47da83813d refactor: Rename getButtonColor to getprimary and update related usages 2025-11-04 15:18:12 +05:30
5d99f3fdfd Merge pull request 'Dashboard_Expense_Widgets_OFW' (#78) from Dashboard_Expense_Widgets_OFW into OnFieldWork
Reviewed-on: #78
2025-11-04 08:46:32 +00:00
03b82764ed Refactor attendance and document controllers to use reactive date ranges, implement reusable DateRangePickerWidget, and enhance filter functionality in attendance and expense screens.
- Updated AttendanceController to use Rx<DateTime> for date ranges.
- Introduced DateRangePickerWidget for selecting date ranges in attendance and expense filters.
- Refactored attendance filter bottom sheet to utilize the new DateRangePickerWidget.
- Enhanced user document filter bottom sheet with date range selection.
- Improved expense filter bottom sheet to include date range selection and refactored UI components for better readability.
- Cleaned up unused code and improved overall code structure for maintainability.
2025-11-04 14:15:45 +05:30
b33b3da6c0 refactor: Rename ProjectProgressChart to AttendanceDashboardChart and update data handling for attendance overview 2025-11-03 17:40:18 +05:30
817672c8b2 refactor: Improve Attendance and Project Progress Charts with enhanced styling and tooltip formatting 2025-11-03 16:51:51 +05:30
4f0261bf0b feat: Add Monthly Expense Dashboard Chart and related data models 2025-11-03 16:43:29 +05:30
b4be463da6 refactor: Adjust inner radius of ExpenseDonutChart for improved visual clarity 2025-11-01 17:14:44 +05:30
99f6c594b9 feat: Enhance tooltip behavior in ExpenseDonutChart to display formatted currency and percentage 2025-11-01 15:52:59 +05:30
9890fbaffe refactor: Update ExpenseDonutChart data labels to display formatted currency instead of percentage 2025-11-01 15:41:05 +05:30
4a5fd1c7cc feat: Update Dashboard with Expense Type Report Chart and enhance loading experience with skeleton loaders 2025-11-01 15:38:58 +05:30
68cac95908 chore: Update version number to 1.0.0+12 in pubspec.yaml 2025-11-01 11:34:50 +05:30
2f283765c1 refactor: Update FAQ and Support screens to use contentTheme for colors and improve UI consistency 2025-10-31 17:20:44 +05:30
d62f0d2c60 feat: Enhance ExpenseByStatusWidget with navigation and filter functionality; update ExpenseMainScreen to fetch expenses after UI initialization 2025-10-31 17:13:43 +05:30
1e39210a29 feat: Implement expense by status skeleton loader for improved loading experience 2025-10-31 16:05:43 +05:30
8dbd21df8b refactor: Update API base URL and change default color theme to green 2025-10-31 15:35:53 +05:30
6568dc70c8 refactor: Update package and bundle identifiers to reflect new naming convention 2025-10-31 15:22:05 +05:30
bc9fc4d6f1 Refactor UserDocumentsPage: Enhance UI with search bar, filter chips, and document cards; implement infinite scrolling and FAB for document upload; improve state management and permission checks. 2025-10-31 14:58:15 +05:30
62eb7b1d97 feat: Enhance theme customization with color theme persistence and toggle functionality 2025-10-31 10:55:12 +05:30
6d5137b103 feat: Add Expense Type Report feature with chart visualization and API integration 2025-10-30 15:50:55 +05:30
f01608e4e7 feat: Implement pending expenses feature with API integration and UI widget 2025-10-30 10:53:38 +05:30
c78231d0fd feat: Add theme customization feature with ThemeEditorWidget
- Introduced ThemeEditorWidget for user-friendly theme selection.
- Added ThemeOption class to manage theme properties.
- Implemented ThemeController to handle theme application logic.
- Updated ThemeCustomizer to allow external theme changes.
- Refactored wave background components to support dynamic colors.
- Updated various screens to utilize the new theme system.
- Enhanced UI elements with consistent styling and improved responsiveness.
2025-10-29 14:30:51 +05:30
cd21a3ac38 refactor: remove unused comment editor and related dependencies, update version 2025-10-27 14:47:56 +05:30
d26e7e3774 refactor: update package and bundle identifiers to com.onfieldwork.marcoaiot 2025-10-24 15:24:14 +05:30
90d1132e1c chnaged colour 2025-10-11 17:41:01 +05:30
8c3493b792 changed theme to green 2025-10-09 14:50:54 +05:30
f281eb5b50 chnaged date format 2025-10-06 10:48:31 +05:30
a1bd9a3108 resolved the email and has application acccess is not populated on edit 2025-10-01 16:59:15 +05:30
843f394ebe made chnages for name 2025-10-01 16:22:15 +05:30
7d9eb3fad2 modified bash file 2025-10-01 16:21:48 +05:30
90c76a1799 made chnage for date selection 2025-10-01 16:21:19 +05:30
e4165f2ee8 added add new project in projectselection 2025-10-01 15:47:21 +05:30
c9e6840161 added api and craeted add project bottomsheet 2025-10-01 15:06:23 +05:30
2b8196b216 made chnages in add contach for assignment bucket 2025-10-01 12:14:37 +05:30
a18c4dad45 removed assign employee icon 2025-09-30 20:50:42 +05:30
55695ef176 added faq 2025-09-30 19:45:25 +05:30
4feb2875f0 removed unwanted files 2025-09-30 17:00:20 +05:30
3515cab0d5 chnaged the login api 2025-09-30 16:51:01 +05:30
3f3185c2f4 removed login flow code 2025-09-30 15:46:14 +05:30
b1b5b52854 removed unwanted code 2025-09-30 15:17:03 +05:30
1993676470 removed the unwanted menus 2025-09-30 14:59:53 +05:30
138 changed files with 13045 additions and 10736 deletions

View File

@ -15,7 +15,7 @@ if (keystorePropertiesFile.exists()) {
android { android {
// Define the namespace for your Android application // Define the namespace for your Android application
namespace = "com.marco.aiot" namespace = "com.marcoonfieldwork.aiot"
// Set the compile SDK version based on Flutter's configuration // Set the compile SDK version based on Flutter's configuration
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
// Set the NDK version based on Flutter's configuration // Set the NDK version based on Flutter's configuration
@ -37,7 +37,7 @@ android {
// Default configuration for your application // Default configuration for your application
defaultConfig { defaultConfig {
// Specify your unique Application ID. This identifies your app on Google Play. // Specify your unique Application ID. This identifies your app on Google Play.
applicationId = "com.marco.aiot" applicationId = "com.marcoonfieldwork.aiot"
// Set minimum and target SDK versions based on Flutter's configuration // Set minimum and target SDK versions based on Flutter's configuration
minSdk = 23 minSdk = 23
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion

View File

@ -9,7 +9,7 @@
"client_info": { "client_info": {
"mobilesdk_app_id": "1:626581282477:android:8d3cf5009ff92ef67ff024", "mobilesdk_app_id": "1:626581282477:android:8d3cf5009ff92ef67ff024",
"android_client_info": { "android_client_info": {
"package_name": "com.marco.aiot" "package_name": "com.marcoonfieldwork.aiot"
} }
}, },
"oauth_client": [], "oauth_client": [],

View File

@ -8,7 +8,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application <application
android:label="Marco" android:label="On Field Work"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 409 KiB

View File

@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# =============================== # ===============================
# Flutter APK Build Script (AAB Disabled) # Flutter APK & AAB Build Script
# =============================== # ===============================
# Exit immediately if a command exits with a non-zero status # Exit immediately if a command exits with a non-zero status
@ -30,19 +30,19 @@ flutter pub get
# ============================== # ==============================
# Step 3: Build AAB (Commented) # Step 3: Build AAB (Commented)
# ============================== # ==============================
# echo -e "${CYAN}🏗 Building AAB file...${NC}" echo -e "${CYAN}🏗 Building AAB file...${NC}"
# flutter build appbundle --release flutter build appbundle --release
# Step 4: Build APK # Step 4: Build APK
echo -e "${CYAN}🏗 Building APK file...${NC}" echo -e "${CYAN}🏗 Building APK file...${NC}"
flutter build apk --release flutter build apk --release
# Step 5: Show output paths # Step 5: Show output paths
# AAB_PATH="$BUILD_DIR/bundle/release/app-release.aab" AAB_PATH="$BUILD_DIR/bundle/release/app-release.aab"
APK_PATH="$BUILD_DIR/apk/release/app-release.apk" APK_PATH="$BUILD_DIR/apk/release/app-release.apk"
echo -e "${GREEN}✅ Build completed successfully!${NC}" echo -e "${GREEN}✅ Build completed successfully!${NC}"
# echo -e "${YELLOW}📍 AAB file: ${CYAN}$AAB_PATH${NC}" echo -e "${YELLOW}📍 AAB file: ${CYAN}$AAB_PATH${NC}"
echo -e "${YELLOW}📍 APK file: ${CYAN}$APK_PATH${NC}" echo -e "${YELLOW}📍 APK file: ${CYAN}$APK_PATH${NC}"
# Optional: open the folder (Mac/Linux) # Optional: open the folder (Mac/Linux)

View File

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

View File

@ -8,6 +8,7 @@ import 'package:intl/intl.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_image_compressor.dart'; import 'package:marco/helpers/widgets/my_image_compressor.dart';
import 'package:marco/helpers/widgets/time_stamp_image_helper.dart';
import 'package:marco/model/attendance/attendance_model.dart'; import 'package:marco/model/attendance/attendance_model.dart';
import 'package:marco/model/project_model.dart'; import 'package:marco/model/project_model.dart';
@ -19,22 +20,27 @@ import 'package:marco/model/attendance/organization_per_project_list_model.dart'
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
class AttendanceController extends GetxController { class AttendanceController extends GetxController {
// Data models // ------------------ Data Models ------------------
List<AttendanceModel> attendances = []; List<AttendanceModel> attendances = [];
List<ProjectModel> projects = []; List<ProjectModel> projects = [];
List<EmployeeModel> employees = []; List<EmployeeModel> employees = [];
List<AttendanceLogModel> attendanceLogs = []; List<AttendanceLogModel> attendanceLogs = [];
List<RegularizationLogModel> regularizationLogs = []; List<RegularizationLogModel> regularizationLogs = [];
List<AttendanceLogViewModel> attendenceLogsView = []; List<AttendanceLogViewModel> attendenceLogsView = [];
// ------------------ Organizations ------------------ // ------------------ Organizations ------------------
List<Organization> organizations = []; List<Organization> organizations = [];
Organization? selectedOrganization; Organization? selectedOrganization;
final isLoadingOrganizations = false.obs; final isLoadingOrganizations = false.obs;
// States // ------------------ States ------------------
String selectedTab = 'todaysAttendance'; String selectedTab = 'todaysAttendance';
DateTime? startDateAttendance;
DateTime? endDateAttendance; // Reactive date range
final Rx<DateTime> startDateAttendance =
DateTime.now().subtract(const Duration(days: 7)).obs;
final Rx<DateTime> endDateAttendance =
DateTime.now().subtract(const Duration(days: 1)).obs;
final isLoading = true.obs; final isLoading = true.obs;
final isLoadingProjects = true.obs; final isLoadingProjects = true.obs;
@ -45,16 +51,12 @@ String selectedTab = 'todaysAttendance';
final uploadingStates = <String, RxBool>{}.obs; final uploadingStates = <String, RxBool>{}.obs;
var showPendingOnly = false.obs; var showPendingOnly = false.obs;
final searchQuery = ''.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
_initializeDefaults(); _initializeDefaults();
// 🔹 Fetch organizations for the selected project
final projectId = Get.find<ProjectController>().selectedProject?.id;
if (projectId != null) {
fetchOrganizations(projectId);
}
} }
void _initializeDefaults() { void _initializeDefaults() {
@ -63,14 +65,38 @@ String selectedTab = 'todaysAttendance';
void _setDefaultDateRange() { void _setDefaultDateRange() {
final today = DateTime.now(); final today = DateTime.now();
startDateAttendance = today.subtract(const Duration(days: 7)); startDateAttendance.value = today.subtract(const Duration(days: 7));
endDateAttendance = today.subtract(const Duration(days: 1)); endDateAttendance.value = today.subtract(const Duration(days: 1));
logSafe( logSafe(
"Default date range set: $startDateAttendance to $endDateAttendance"); "Default date range set: ${startDateAttendance.value} to ${endDateAttendance.value}");
} }
// ------------------ Project & Employee ------------------ // ------------------ Computed Filters ------------------
/// Called when a notification says attendance has been updated List<EmployeeModel> get filteredEmployees {
if (searchQuery.value.isEmpty) return employees;
return employees
.where((e) =>
e.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
List<AttendanceLogModel> get filteredLogs {
if (searchQuery.value.isEmpty) return attendanceLogs;
return attendanceLogs
.where((log) =>
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
List<RegularizationLogModel> get filteredRegularizationLogs {
if (searchQuery.value.isEmpty) return regularizationLogs;
return regularizationLogs
.where((log) =>
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
// ------------------ Project & Employee APIs ------------------
Future<void> refreshDataFromNotification({String? projectId}) async { Future<void> refreshDataFromNotification({String? projectId}) async {
projectId ??= Get.find<ProjectController>().selectedProject?.id; projectId ??= Get.find<ProjectController>().selectedProject?.id;
if (projectId == null) { if (projectId == null) {
@ -83,36 +109,6 @@ String selectedTab = 'todaysAttendance';
"Attendance data refreshed from notification for project $projectId"); "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 { Future<void> fetchTodaysAttendance(String? projectId) async {
if (projectId == null) return; if (projectId == null) return;
@ -132,6 +128,7 @@ String selectedTab = 'todaysAttendance';
logSafe("Failed to fetch employees for project $projectId", logSafe("Failed to fetch employees for project $projectId",
level: LogLevel.error); level: LogLevel.error);
} }
isLoadingEmployees.value = false; isLoadingEmployees.value = false;
update(); update();
} }
@ -151,7 +148,6 @@ String selectedTab = 'todaysAttendance';
} }
// ------------------ Attendance Capture ------------------ // ------------------ Attendance Capture ------------------
Future<bool> captureAndUploadAttendance( Future<bool> captureAndUploadAttendance(
String id, String id,
String employeeId, String employeeId,
@ -159,8 +155,8 @@ String selectedTab = 'todaysAttendance';
String comment = "Marked via mobile app", String comment = "Marked via mobile app",
required int action, required int action,
bool imageCapture = true, bool imageCapture = true,
String? markTime, // still optional in controller String? markTime,
String? date, // new optional param String? date,
}) async { }) async {
try { try {
uploadingStates[employeeId]?.value = true; uploadingStates[employeeId]?.value = true;
@ -174,8 +170,11 @@ String selectedTab = 'todaysAttendance';
return false; return false;
} }
final timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: File(image.path));
final compressedBytes = final compressedBytes =
await compressImageToUnder100KB(File(image.path)); await compressImageToUnder100KB(timestampedFile);
if (compressedBytes == null) { if (compressedBytes == null) {
logSafe("Image compression failed.", level: LogLevel.error); logSafe("Image compression failed.", level: LogLevel.error);
return false; return false;
@ -193,29 +192,20 @@ String selectedTab = 'todaysAttendance';
? ApiService.generateImageName(employeeId, employees.length + 1) ? ApiService.generateImageName(employeeId, employees.length + 1)
: ""; : "";
// ---------------- DATE / TIME LOGIC ----------------
final now = DateTime.now(); final now = DateTime.now();
// Default effectiveDate = now
DateTime effectiveDate = now; DateTime effectiveDate = now;
if (action == 1) { if (action == 1) {
// Checkout
// Try to find today's open log for this employee
final log = attendanceLogs.firstWhereOrNull( final log = attendanceLogs.firstWhereOrNull(
(log) => log.employeeId == employeeId && log.checkOut == null, (log) => log.employeeId == employeeId && log.checkOut == null,
); );
if (log?.checkIn != null) { if (log?.checkIn != null) effectiveDate = log!.checkIn!;
effectiveDate = log!.checkIn!; // use check-in date
}
} }
final formattedMarkTime = markTime ?? DateFormat('hh:mm a').format(now); final formattedMarkTime = markTime ?? DateFormat('hh:mm a').format(now);
final formattedDate = final formattedDate =
date ?? DateFormat('yyyy-MM-dd').format(effectiveDate); date ?? DateFormat('yyyy-MM-dd').format(effectiveDate);
// ---------------- API CALL ----------------
final result = await ApiService.uploadAttendanceImage( final result = await ApiService.uploadAttendanceImage(
id, id,
employeeId, employeeId,
@ -264,7 +254,6 @@ String selectedTab = 'todaysAttendance';
} }
// ------------------ Attendance Logs ------------------ // ------------------ Attendance Logs ------------------
Future<void> fetchAttendanceLogs(String? projectId, Future<void> fetchAttendanceLogs(String? projectId,
{DateTime? dateFrom, DateTime? dateTo}) async { {DateTime? dateFrom, DateTime? dateTo}) async {
if (projectId == null) return; if (projectId == null) return;
@ -313,7 +302,6 @@ String selectedTab = 'todaysAttendance';
} }
// ------------------ Regularization Logs ------------------ // ------------------ Regularization Logs ------------------
Future<void> fetchRegularizationLogs(String? projectId) async { Future<void> fetchRegularizationLogs(String? projectId) async {
if (projectId == null) return; if (projectId == null) return;
@ -337,7 +325,6 @@ String selectedTab = 'todaysAttendance';
} }
// ------------------ Attendance Log View ------------------ // ------------------ Attendance Log View ------------------
Future<void> fetchLogsView(String? id) async { Future<void> fetchLogsView(String? id) async {
if (id == null) return; if (id == null) return;
@ -360,7 +347,6 @@ String selectedTab = 'todaysAttendance';
} }
// ------------------ Combined Load ------------------ // ------------------ Combined Load ------------------
Future<void> loadAttendanceData(String projectId) async { Future<void> loadAttendanceData(String projectId) async {
isLoading.value = true; isLoading.value = true;
await fetchProjectData(projectId); await fetchProjectData(projectId);
@ -372,7 +358,6 @@ String selectedTab = 'todaysAttendance';
await fetchOrganizations(projectId); await fetchOrganizations(projectId);
// Call APIs depending on the selected tab only
switch (selectedTab) { switch (selectedTab) {
case 'todaysAttendance': case 'todaysAttendance':
await fetchTodaysAttendance(projectId); await fetchTodaysAttendance(projectId);
@ -380,8 +365,8 @@ String selectedTab = 'todaysAttendance';
case 'attendanceLogs': case 'attendanceLogs':
await fetchAttendanceLogs( await fetchAttendanceLogs(
projectId, projectId,
dateFrom: startDateAttendance, dateFrom: startDateAttendance.value,
dateTo: endDateAttendance, dateTo: endDateAttendance.value,
); );
break; break;
case 'regularizationRequests': case 'regularizationRequests':
@ -395,7 +380,6 @@ String selectedTab = 'todaysAttendance';
} }
// ------------------ UI Interaction ------------------ // ------------------ UI Interaction ------------------
Future<void> selectDateRangeForAttendance( Future<void> selectDateRangeForAttendance(
BuildContext context, AttendanceController controller) async { BuildContext context, AttendanceController controller) async {
final today = DateTime.now(); final today = DateTime.now();
@ -405,16 +389,17 @@ String selectedTab = 'todaysAttendance';
firstDate: DateTime(2022), firstDate: DateTime(2022),
lastDate: today.subtract(const Duration(days: 1)), lastDate: today.subtract(const Duration(days: 1)),
initialDateRange: DateTimeRange( initialDateRange: DateTimeRange(
start: startDateAttendance ?? today.subtract(const Duration(days: 7)), start: startDateAttendance.value,
end: endDateAttendance ?? today.subtract(const Duration(days: 1)), end: endDateAttendance.value,
), ),
); );
if (picked != null) { if (picked != null) {
startDateAttendance = picked.start; startDateAttendance.value = picked.start;
endDateAttendance = picked.end; endDateAttendance.value = picked.end;
logSafe( logSafe(
"Date range selected: $startDateAttendance to $endDateAttendance"); "Date range selected: ${startDateAttendance.value} to ${endDateAttendance.value}");
await controller.fetchAttendanceLogs( await controller.fetchAttendanceLogs(
Get.find<ProjectController>().selectedProject?.id, Get.find<ProjectController>().selectedProject?.id,

View File

@ -1,3 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/my_controller.dart'; import 'package:marco/controller/my_controller.dart';
@ -79,6 +80,7 @@ class LoginController extends MyController {
enableRemoteLogging(); enableRemoteLogging();
logSafe("✅ Remote logging enabled after login."); logSafe("✅ Remote logging enabled after login.");
final fcmToken = await LocalStorage.getFcmToken(); final fcmToken = await LocalStorage.getFcmToken();
if (fcmToken?.isNotEmpty ?? false) { if (fcmToken?.isNotEmpty ?? false) {
final success = await AuthService.registerDeviceToken(fcmToken!); final success = await AuthService.registerDeviceToken(fcmToken!);
@ -89,9 +91,9 @@ class LoginController extends MyController {
level: LogLevel.warning); level: LogLevel.warning);
} }
logSafe("Login successful for user: ${loginData['username']}");
Get.toNamed('/select_tenant'); logSafe("Login successful for user: ${loginData['username']}");
Get.toNamed('/home');
} }
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Exception during login", logSafe("Exception during login",

View File

@ -3,6 +3,10 @@ import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:marco/model/dashboard/project_progress_model.dart'; import 'package:marco/model/dashboard/project_progress_model.dart';
import 'package:marco/model/dashboard/pending_expenses_model.dart';
import 'package:marco/model/dashboard/expense_type_report_model.dart';
import 'package:marco/model/dashboard/monthly_expence_model.dart';
import 'package:marco/model/expense/expense_type_model.dart';
class DashboardController extends GetxController { class DashboardController extends GetxController {
// ========================= // =========================
@ -47,7 +51,49 @@ class DashboardController extends GetxController {
final List<String> ranges = ['7D', '15D', '30D']; final List<String> ranges = ['7D', '15D', '30D'];
// Inject ProjectController // Inject ProjectController
final ProjectController projectController = Get.find<ProjectController>(); final ProjectController projectController = Get.put(ProjectController());
// Pending Expenses overview
// =========================
final RxBool isPendingExpensesLoading = false.obs;
final Rx<PendingExpensesData?> pendingExpensesData =
Rx<PendingExpensesData?>(null);
// =========================
// Expense Type Report
// =========================
final RxBool isExpenseTypeReportLoading = false.obs;
final Rx<ExpenseTypeReportData?> expenseTypeReportData =
Rx<ExpenseTypeReportData?>(null);
final Rx<DateTime> expenseReportStartDate =
DateTime.now().subtract(const Duration(days: 15)).obs;
final Rx<DateTime> expenseReportEndDate = DateTime.now().obs;
// =========================
// Monthly Expense Report
// =========================
final RxBool isMonthlyExpenseLoading = false.obs;
final RxList<MonthlyExpenseData> monthlyExpenseList =
<MonthlyExpenseData>[].obs;
// =========================
// Monthly Expense Report Filters
// =========================
final Rx<MonthlyExpenseDuration> selectedMonthlyExpenseDuration =
MonthlyExpenseDuration.twelveMonths.obs;
final RxInt selectedMonthsCount = 12.obs;
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
final Rx<ExpenseTypeModel?> selectedExpenseType = Rx<ExpenseTypeModel?>(null);
void updateSelectedExpenseType(ExpenseTypeModel? type) {
selectedExpenseType.value = type;
// Debug print to verify
print('Selected: ${type?.name ?? "All Types"}');
if (type == null) {
fetchMonthlyExpenses();
} else {
fetchMonthlyExpenses(categoryId: type.id);
}
}
@override @override
void onInit() { void onInit() {
@ -64,7 +110,12 @@ class DashboardController extends GetxController {
ever<String>(projectController.selectedProjectId, (id) { ever<String>(projectController.selectedProjectId, (id) {
fetchAllDashboardData(); fetchAllDashboardData();
}); });
everAll([expenseReportStartDate, expenseReportEndDate], (_) {
fetchExpenseTypeReport(
startDate: expenseReportStartDate.value,
endDate: expenseReportEndDate.value,
);
});
// React to range changes // React to range changes
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance()); ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
ever(projectSelectedRange, (_) => fetchProjectProgress()); ever(projectSelectedRange, (_) => fetchProjectProgress());
@ -147,9 +198,113 @@ class DashboardController extends GetxController {
fetchProjectProgress(), fetchProjectProgress(),
fetchDashboardTasks(projectId: projectId), fetchDashboardTasks(projectId: projectId),
fetchDashboardTeams(projectId: projectId), fetchDashboardTeams(projectId: projectId),
fetchPendingExpenses(),
fetchExpenseTypeReport(
startDate: expenseReportStartDate.value,
endDate: expenseReportEndDate.value,
),
fetchMonthlyExpenses(),
fetchMasterData()
]); ]);
} }
void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) {
selectedMonthlyExpenseDuration.value = duration;
// Set months count based on selection
switch (duration) {
case MonthlyExpenseDuration.oneMonth:
selectedMonthsCount.value = 1;
break;
case MonthlyExpenseDuration.threeMonths:
selectedMonthsCount.value = 3;
break;
case MonthlyExpenseDuration.sixMonths:
selectedMonthsCount.value = 6;
break;
case MonthlyExpenseDuration.twelveMonths:
selectedMonthsCount.value = 12;
break;
case MonthlyExpenseDuration.all:
selectedMonthsCount.value = 0; // 0 = All months in your API
break;
}
// Re-fetch updated data
fetchMonthlyExpenses();
}
Future<void> fetchMasterData() async {
try {
final expenseTypesData = await ApiService.getMasterExpenseTypes();
if (expenseTypesData is List) {
expenseTypes.value =
expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList();
}
} catch (e) {
logSafe('Error fetching master data', level: LogLevel.error, error: e);
}
}
Future<void> fetchMonthlyExpenses({String? categoryId}) async {
try {
isMonthlyExpenseLoading.value = true;
int months = selectedMonthsCount.value;
logSafe(
'Fetching Monthly Expense Report for last $months months'
'${categoryId != null ? ' (categoryId: $categoryId)' : ''}',
level: LogLevel.info,
);
final response = await ApiService.getDashboardMonthlyExpensesApi(
categoryId: categoryId,
months: months,
);
if (response != null && response.success) {
monthlyExpenseList.value = response.data;
logSafe('Monthly Expense Report fetched successfully.',
level: LogLevel.info);
} else {
monthlyExpenseList.clear();
logSafe('Failed to fetch Monthly Expense Report.',
level: LogLevel.error);
}
} catch (e, st) {
monthlyExpenseList.clear();
logSafe('Error fetching Monthly Expense Report',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isMonthlyExpenseLoading.value = false;
}
}
Future<void> fetchPendingExpenses() async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
try {
isPendingExpensesLoading.value = true;
final response =
await ApiService.getPendingExpensesApi(projectId: projectId);
if (response != null && response.success) {
pendingExpensesData.value = response.data;
logSafe('Pending expenses fetched successfully.', level: LogLevel.info);
} else {
pendingExpensesData.value = null;
logSafe('Failed to fetch pending expenses.', level: LogLevel.error);
}
} catch (e, st) {
pendingExpensesData.value = null;
logSafe('Error fetching pending expenses',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isPendingExpensesLoading.value = false;
}
}
// ========================= // =========================
// API Calls // API Calls
// ========================= // =========================
@ -182,6 +337,39 @@ class DashboardController extends GetxController {
} }
} }
Future<void> fetchExpenseTypeReport({
required DateTime startDate,
required DateTime endDate,
}) async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
try {
isExpenseTypeReportLoading.value = true;
final response = await ApiService.getExpenseTypeReportApi(
projectId: projectId,
startDate: startDate,
endDate: endDate,
);
if (response != null && response.success) {
expenseTypeReportData.value = response.data;
logSafe('Expense Type Report fetched successfully.',
level: LogLevel.info);
} else {
expenseTypeReportData.value = null;
logSafe('Failed to fetch Expense Type Report.', level: LogLevel.error);
}
} catch (e, st) {
expenseTypeReportData.value = null;
logSafe('Error fetching Expense Type Report',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isExpenseTypeReportLoading.value = false;
}
}
Future<void> fetchProjectProgress() async { Future<void> fetchProjectProgress() async {
final String projectId = projectController.selectedProjectId.value; final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return; if (projectId.isEmpty) return;
@ -260,3 +448,11 @@ class DashboardController extends GetxController {
} }
} }
} }
enum MonthlyExpenseDuration {
oneMonth,
threeMonths,
sixMonths,
twelveMonths,
all,
}

View File

@ -10,7 +10,7 @@ class AddContactController extends GetxController {
final RxList<String> tags = <String>[].obs; final RxList<String> tags = <String>[].obs;
final RxString selectedCategory = ''.obs; final RxString selectedCategory = ''.obs;
final RxString selectedBucket = ''.obs; final RxList<String> selectedBuckets = <String>[].obs;
final RxString selectedProject = ''.obs; final RxString selectedProject = ''.obs;
final RxList<String> enteredTags = <String>[].obs; final RxList<String> enteredTags = <String>[].obs;
@ -50,7 +50,7 @@ class AddContactController extends GetxController {
void resetForm() { void resetForm() {
selectedCategory.value = ''; selectedCategory.value = '';
selectedProject.value = ''; selectedProject.value = '';
selectedBucket.value = ''; selectedBuckets.clear();
enteredTags.clear(); enteredTags.clear();
filteredSuggestions.clear(); filteredSuggestions.clear();
filteredOrgSuggestions.clear(); filteredOrgSuggestions.clear();
@ -100,7 +100,21 @@ class AddContactController extends GetxController {
isSubmitting.value = true; isSubmitting.value = true;
final categoryId = categoriesMap[selectedCategory.value]; final categoryId = categoriesMap[selectedCategory.value];
final bucketId = bucketsMap[selectedBucket.value]; final bucketIds = selectedBuckets
.map((name) => bucketsMap[name])
.whereType<String>()
.toList();
if (bucketIds.isEmpty) {
showAppSnackbar(
title: "Missing Buckets",
message: "Please select at least one bucket.",
type: SnackbarType.warning,
);
isSubmitting.value = false;
return;
}
final projectIds = selectedProjects final projectIds = selectedProjects
.map((name) => projectsMap[name]) .map((name) => projectsMap[name])
.whereType<String>() .whereType<String>()
@ -126,10 +140,10 @@ class AddContactController extends GetxController {
return; return;
} }
if (selectedBucket.value.trim().isEmpty || bucketId == null) { if (selectedBuckets.isEmpty) {
showAppSnackbar( showAppSnackbar(
title: "Missing Bucket", title: "Missing Bucket",
message: "Please select a bucket.", message: "Please select at least one bucket.",
type: SnackbarType.warning, type: SnackbarType.warning,
); );
isSubmitting.value = false; isSubmitting.value = false;
@ -151,7 +165,7 @@ class AddContactController extends GetxController {
if (selectedCategory.value.isNotEmpty && categoryId != null) if (selectedCategory.value.isNotEmpty && categoryId != null)
"contactCategoryId": categoryId, "contactCategoryId": categoryId,
if (projectIds.isNotEmpty) "projectIds": projectIds, if (projectIds.isNotEmpty) "projectIds": projectIds,
"bucketIds": [bucketId], "bucketIds": bucketIds,
if (enteredTags.isNotEmpty) "tags": tagObjects, if (enteredTags.isNotEmpty) "tags": tagObjects,
if (emails.isNotEmpty) "contactEmails": emails, if (emails.isNotEmpty) "contactEmails": emails,
if (phones.isNotEmpty) "contactPhones": phones, if (phones.isNotEmpty) "contactPhones": phones,

View File

@ -3,56 +3,65 @@ import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/document/document_filter_model.dart'; import 'package:marco/model/document/document_filter_model.dart';
import 'package:marco/model/document/documents_list_model.dart'; import 'package:marco/model/document/documents_list_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class DocumentController extends GetxController { class DocumentController extends GetxController {
// ------------------ Observables --------------------- // ==================== Observables ====================
var isLoading = false.obs; final isLoading = false.obs;
var documents = <DocumentItem>[].obs; final documents = <DocumentItem>[].obs;
var filters = Rxn<DocumentFiltersData>(); final filters = Rxn<DocumentFiltersData>();
// Selected filters (multi-select support) // Selected filters (multi-select)
var selectedUploadedBy = <String>[].obs; final selectedUploadedBy = <String>[].obs;
var selectedCategory = <String>[].obs; final selectedCategory = <String>[].obs;
var selectedType = <String>[].obs; final selectedType = <String>[].obs;
var selectedTag = <String>[].obs; final selectedTag = <String>[].obs;
// Pagination state // Pagination
var pageNumber = 1.obs; final pageNumber = 1.obs;
final int pageSize = 20; final pageSize = 20;
var hasMore = true.obs; final hasMore = true.obs;
// Error message // Error handling
var errorMessage = "".obs; final errorMessage = ''.obs;
// NEW: show inactive toggle // Preferences
var showInactive = false.obs; final showInactive = false.obs;
// NEW: search // Search
var searchQuery = ''.obs; final searchQuery = ''.obs;
var searchController = TextEditingController(); final searchController = TextEditingController();
// New filter fields
var isUploadedAt = true.obs;
var isVerified = RxnBool();
var startDate = Rxn<String>();
var endDate = Rxn<String>();
// ------------------ API Calls ----------------------- // Additional filters
final isUploadedAt = true.obs;
final isVerified = RxnBool();
final startDate = Rxn<DateTime>();
final endDate = Rxn<DateTime>();
/// Fetch Document Filters for an Entity // ==================== Lifecycle ====================
@override
void onClose() {
// Don't dispose searchController here - it's managed by the page
super.onClose();
}
// ==================== API Methods ====================
/// Fetch document filters for entity
Future<void> fetchFilters(String entityTypeId) async { Future<void> fetchFilters(String entityTypeId) async {
try { try {
isLoading.value = true;
final response = await ApiService.getDocumentFilters(entityTypeId); final response = await ApiService.getDocumentFilters(entityTypeId);
if (response != null && response.success) { if (response != null && response.success) {
filters.value = response.data; filters.value = response.data;
} else { } else {
errorMessage.value = response?.message ?? "Failed to fetch filters"; errorMessage.value = response?.message ?? 'Failed to fetch filters';
_showError('Failed to load filters');
} }
} catch (e) { } catch (e) {
errorMessage.value = "Error fetching filters: $e"; errorMessage.value = 'Error fetching filters: $e';
} finally { _showError('Error loading filters');
isLoading.value = false; debugPrint('❌ Error fetching filters: $e');
} }
} }
@ -65,53 +74,43 @@ class DocumentController extends GetxController {
}) async { }) async {
try { try {
isLoading.value = true; isLoading.value = true;
final success =
await ApiService.deleteDocumentApi(id: id, isActive: isActive); final success = await ApiService.deleteDocumentApi(
id: id,
isActive: isActive,
);
if (success) { if (success) {
// 🔥 Always fetch fresh list after toggle // Refresh list after state change
await fetchDocuments( await fetchDocuments(
entityTypeId: entityTypeId, entityTypeId: entityTypeId,
entityId: entityId, entityId: entityId,
reset: true, reset: true,
); );
showAppSnackbar(
title: 'Success',
message: 'Document state updated successfully',
type: SnackbarType.success,
);
return true; return true;
} else { } else {
errorMessage.value = "Failed to update document state"; errorMessage.value = 'Failed to update document state';
_showError('Failed to update document state');
return false; return false;
} }
} catch (e) { } catch (e) {
errorMessage.value = "Error updating document: $e"; errorMessage.value = 'Error updating document: $e';
_showError('Error updating document: $e');
debugPrint('❌ Error toggling document state: $e');
return false; return false;
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
} }
/// Permanently delete a document (or deactivate depending on API) /// Fetch documents for entity with pagination
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({ Future<void> fetchDocuments({
required String entityTypeId, required String entityTypeId,
required String entityId, required String entityId,
@ -126,14 +125,15 @@ class DocumentController extends GetxController {
hasMore.value = true; hasMore.value = true;
} }
if (!hasMore.value) return; if (!hasMore.value && !reset) return;
if (isLoading.value) return;
isLoading.value = true; isLoading.value = true;
final response = await ApiService.getDocumentListApi( final response = await ApiService.getDocumentListApi(
entityTypeId: entityTypeId, entityTypeId: entityTypeId,
entityId: entityId, entityId: entityId,
filter: filter ?? "", filter: filter ?? '',
searchString: searchString ?? searchQuery.value, searchString: searchString ?? searchQuery.value,
pageNumber: pageNumber.value, pageNumber: pageNumber.value,
pageSize: pageSize, pageSize: pageSize,
@ -147,19 +147,27 @@ class DocumentController extends GetxController {
} else { } else {
hasMore.value = false; hasMore.value = false;
} }
errorMessage.value = '';
} else { } else {
errorMessage.value = response?.message ?? "Failed to fetch documents"; errorMessage.value = response?.message ?? 'Failed to fetch documents';
if (documents.isEmpty) {
_showError('Failed to load documents');
}
} }
} catch (e) { } catch (e) {
errorMessage.value = "Error fetching documents: $e"; errorMessage.value = 'Error fetching documents: $e';
if (documents.isEmpty) {
_showError('Error loading documents');
}
debugPrint('❌ Error fetching documents: $e');
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
} }
// ------------------ Helpers ----------------------- // ==================== Helper Methods ====================
/// Clear selected filters /// Clear all selected filters
void clearFilters() { void clearFilters() {
selectedUploadedBy.clear(); selectedUploadedBy.clear();
selectedCategory.clear(); selectedCategory.clear();
@ -171,11 +179,35 @@ class DocumentController extends GetxController {
endDate.value = null; endDate.value = null;
} }
/// Check if any filters are active (for red dot indicator) /// Check if any filters are active
bool hasActiveFilters() { bool hasActiveFilters() {
return selectedUploadedBy.isNotEmpty || return selectedUploadedBy.isNotEmpty ||
selectedCategory.isNotEmpty || selectedCategory.isNotEmpty ||
selectedType.isNotEmpty || selectedType.isNotEmpty ||
selectedTag.isNotEmpty; selectedTag.isNotEmpty ||
startDate.value != null ||
endDate.value != null ||
isVerified.value != null;
}
/// Show error message
void _showError(String message) {
showAppSnackbar(
title: 'Error',
message: message,
type: SnackbarType.error,
);
}
/// Reset controller state
void reset() {
documents.clear();
clearFilters();
searchController.clear();
searchQuery.value = '';
pageNumber.value = 1;
hasMore.value = true;
showInactive.value = false;
errorMessage.value = '';
} }
} }

View File

@ -73,7 +73,8 @@ class AddEmployeeController extends MyController {
controller: TextEditingController(), controller: TextEditingController(),
); );
logSafe('Fields initialized for first_name, phone_number, last_name, email.'); logSafe(
'Fields initialized for first_name, phone_number, last_name, email.');
} }
// Prefill fields in edit mode // Prefill fields in edit mode
@ -87,7 +88,8 @@ class AddEmployeeController extends MyController {
editingEmployeeData?['phone_number'] ?? ''; editingEmployeeData?['phone_number'] ?? '';
selectedGender = editingEmployeeData?['gender'] != null selectedGender = editingEmployeeData?['gender'] != null
? Gender.values.firstWhereOrNull((g) => g.name == editingEmployeeData!['gender']) ? Gender.values
.firstWhereOrNull((g) => g.name == editingEmployeeData!['gender'])
: null; : null;
basicValidator.getController('email')?.text = basicValidator.getController('email')?.text =
@ -121,12 +123,24 @@ class AddEmployeeController extends MyController {
if (result != null) { if (result != null) {
roles = List<Map<String, dynamic>>.from(result); roles = List<Map<String, dynamic>>.from(result);
logSafe('Roles fetched successfully.'); logSafe('Roles fetched successfully.');
// If editing, and role already selected, update the role controller text here
if (editingEmployeeData != null && selectedRoleId != null) {
final selectedRole = roles.firstWhereOrNull(
(r) => r['id'] == selectedRoleId,
);
if (selectedRole != null) {
update();
}
}
update(); update();
} else { } else {
logSafe('Failed to fetch roles: null result', level: LogLevel.error); logSafe('Failed to fetch roles: null result', level: LogLevel.error);
} }
} catch (e, st) { } catch (e, st) {
logSafe('Error fetching roles', level: LogLevel.error, error: e, stackTrace: st); logSafe('Error fetching roles',
level: LogLevel.error, error: e, stackTrace: st);
} }
} }
@ -156,7 +170,8 @@ class AddEmployeeController extends MyController {
final firstName = basicValidator.getController('first_name')?.text.trim(); final firstName = basicValidator.getController('first_name')?.text.trim();
final lastName = basicValidator.getController('last_name')?.text.trim(); final lastName = basicValidator.getController('last_name')?.text.trim();
final phoneNumber = basicValidator.getController('phone_number')?.text.trim(); final phoneNumber =
basicValidator.getController('phone_number')?.text.trim();
try { try {
// sanitize orgId before sending // sanitize orgId before sending
@ -216,7 +231,8 @@ class AddEmployeeController extends MyController {
showAppSnackbar( showAppSnackbar(
title: 'Permission Required', title: 'Permission Required',
message: 'Please allow Contacts permission from settings to pick a contact.', message:
'Please allow Contacts permission from settings to pick a contact.',
type: SnackbarType.warning, type: SnackbarType.warning,
); );
return false; return false;

View File

@ -17,6 +17,7 @@ import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/employees/employee_model.dart'; import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/payment_types_model.dart';
import 'package:marco/helpers/widgets/time_stamp_image_helper.dart';
class AddExpenseController extends GetxController { class AddExpenseController extends GetxController {
// --- Text Controllers --- // --- Text Controllers ---
@ -65,6 +66,7 @@ class AddExpenseController extends GetxController {
final paymentModes = <PaymentModeModel>[].obs; final paymentModes = <PaymentModeModel>[].obs;
final allEmployees = <EmployeeModel>[].obs; final allEmployees = <EmployeeModel>[].obs;
final employeeSearchResults = <EmployeeModel>[].obs; final employeeSearchResults = <EmployeeModel>[].obs;
final isProcessingAttachment = false.obs;
String? editingExpenseId; String? editingExpenseId;
@ -252,9 +254,22 @@ class AddExpenseController extends GetxController {
Future<void> pickFromCamera() async { Future<void> pickFromCamera() async {
try { try {
final pickedFile = await _picker.pickImage(source: ImageSource.camera); final pickedFile = await _picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) attachments.add(File(pickedFile.path)); if (pickedFile != null) {
isProcessingAttachment.value = true; // start loading
File imageFile = File(pickedFile.path);
// Add timestamp to the captured image
File timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: imageFile,
);
attachments.add(timestampedFile);
attachments.refresh(); // refresh UI
}
} catch (e) { } catch (e) {
_errorSnackbar("Camera error: $e"); _errorSnackbar("Camera error: $e");
} finally {
isProcessingAttachment.value = false; // stop loading
} }
} }

View File

@ -0,0 +1,296 @@
// payment_request_controller.dart
import 'dart:io';
import 'dart:convert';
import 'package:get/get.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:mime/mime.dart';
import 'package:intl/intl.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/helpers/widgets/time_stamp_image_helper.dart';
import 'package:marco/model/finance/expense_category_model.dart';
import 'package:marco/model/finance/currency_list_model.dart';
class AddPaymentRequestController extends GetxController {
// Loading States
final isLoadingPayees = false.obs;
final isLoadingCategories = false.obs;
final isLoadingCurrencies = false.obs;
final isProcessingAttachment = false.obs;
final isSubmitting = false.obs;
// Data Lists
final payees = <String>[].obs;
final categories = <ExpenseCategory>[].obs;
final currencies = <Currency>[].obs;
final globalProjects = <Map<String, dynamic>>[].obs;
// Selected Values
final selectedProject = Rx<Map<String, dynamic>?>(null);
final selectedCategory = Rx<ExpenseCategory?>(null);
final selectedPayee = ''.obs;
final selectedCurrency = Rx<Currency?>(null);
final isAdvancePayment = false.obs;
final selectedDueDate = Rx<DateTime?>(null);
// Text Controllers
final titleController = TextEditingController();
final dueDateController = TextEditingController();
final amountController = TextEditingController();
final descriptionController = TextEditingController();
// Attachments
final attachments = <File>[].obs;
final existingAttachments = <Map<String, dynamic>>[].obs;
final ImagePicker _picker = ImagePicker();
@override
void onInit() {
super.onInit();
fetchAllMasterData();
fetchGlobalProjects();
}
@override
void onClose() {
titleController.dispose();
dueDateController.dispose();
amountController.dispose();
descriptionController.dispose();
super.onClose();
}
/// Fetch all master data concurrently
Future<void> fetchAllMasterData() async {
await Future.wait([
_fetchData(
payees, ApiService.getExpensePaymentRequestPayeeApi, isLoadingPayees),
_fetchData(categories, ApiService.getMasterExpenseCategoriesApi,
isLoadingCategories),
_fetchData(
currencies, ApiService.getMasterCurrenciesApi, isLoadingCurrencies),
]);
}
/// Generic fetch handler
Future<void> _fetchData<T>(
RxList<T> list, Future<dynamic> Function() apiCall, RxBool loader) async {
try {
loader.value = true;
final response = await apiCall();
if (response != null && response.data.isNotEmpty) {
list.value = response.data;
} else {
list.clear();
}
} catch (e) {
logSafe("Error fetching data: $e", level: LogLevel.error);
list.clear();
} finally {
loader.value = false;
}
}
/// Fetch projects
Future<void> fetchGlobalProjects() async {
try {
final response = await ApiService.getGlobalProjects();
globalProjects.value = (response ?? [])
.map<Map<String, dynamic>>((e) => {
'id': e['id']?.toString() ?? '',
'name': e['name']?.toString().trim() ?? '',
})
.where((p) => p['id']!.isNotEmpty && p['name']!.isNotEmpty)
.toList();
} catch (e) {
logSafe("Error fetching projects: $e", level: LogLevel.error);
globalProjects.clear();
}
}
/// Pick due date
Future<void> pickDueDate(BuildContext context) async {
final pickedDate = await showDatePicker(
context: context,
initialDate: selectedDueDate.value ?? DateTime.now(),
firstDate: DateTime(DateTime.now().year - 5),
lastDate: DateTime(DateTime.now().year + 5),
);
if (pickedDate != null) {
selectedDueDate.value = pickedDate;
dueDateController.text = DateFormat('dd MMM yyyy').format(pickedDate);
}
}
/// Generic file picker for multiple sources
Future<void> pickAttachments(
{bool fromGallery = false, bool fromCamera = false}) async {
try {
if (fromCamera) {
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) {
isProcessingAttachment.value = true;
final timestamped = await TimestampImageHelper.addTimestamp(
imageFile: File(pickedFile.path));
attachments.add(timestamped);
}
} else if (fromGallery) {
final pickedFile = await _picker.pickImage(source: ImageSource.gallery);
if (pickedFile != null) attachments.add(File(pickedFile.path));
} else {
final result = await FilePicker.platform
.pickFiles(type: FileType.any, allowMultiple: true);
if (result != null && result.paths.isNotEmpty)
attachments.addAll(result.paths.whereType<String>().map(File.new));
}
attachments.refresh();
} catch (e) {
_errorSnackbar("Attachment error: $e");
} finally {
isProcessingAttachment.value = false;
}
}
/// Selection handlers
void selectProject(Map<String, dynamic> project) =>
selectedProject.value = project;
void selectCategory(ExpenseCategory category) =>
selectedCategory.value = category;
void selectPayee(String payee) => selectedPayee.value = payee;
void selectCurrency(Currency currency) => selectedCurrency.value = currency;
void addAttachment(File file) => attachments.add(file);
void removeAttachment(File file) => attachments.remove(file);
/// Build attachment payload
Future<List<Map<String, dynamic>>> buildAttachmentPayload() async {
final existingPayload = existingAttachments
.map((e) => {
"documentId": e['documentId'],
"fileName": e['fileName'],
"contentType": e['contentType'] ?? 'application/octet-stream',
"fileSize": e['fileSize'] ?? 0,
"description": "",
"url": e['url'],
"isActive": e['isActive'] ?? true,
"base64Data": "",
})
.toList();
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": "",
};
}));
return [...existingPayload, ...newPayload];
}
/// Submit payment request (Project API style)
Future<bool> submitPaymentRequest() async {
if (isSubmitting.value) return false;
try {
isSubmitting.value = true;
// Validate form
if (!_validateForm()) return false;
// Build attachment payload
final billAttachments = await buildAttachmentPayload();
final payload = {
"title": titleController.text.trim(),
"projectId": selectedProject.value?['id'] ?? '',
"expenseCategoryId": selectedCategory.value?.id ?? '',
"amount": double.tryParse(amountController.text.trim()) ?? 0,
"currencyId": selectedCurrency.value?.id ?? '',
"description": descriptionController.text.trim(),
"payee": selectedPayee.value,
"dueDate": selectedDueDate.value?.toIso8601String(),
"isAdvancePayment": isAdvancePayment.value,
"billAttachments": billAttachments.map((a) {
return {
"fileName": a['fileName'],
"fileSize": a['fileSize'],
"contentType": a['contentType'],
};
}).toList(),
};
logSafe("💡 Submitting Payment Request: ${jsonEncode(payload)}");
final success = await ApiService.createExpensePaymentRequestApi(
title: payload['title'],
projectId: payload['projectId'],
expenseCategoryId: payload['expenseCategoryId'],
amount: payload['amount'],
currencyId: payload['currencyId'],
description: payload['description'],
payee: payload['payee'],
dueDate: selectedDueDate.value,
isAdvancePayment: payload['isAdvancePayment'],
billAttachments: billAttachments,
);
logSafe("💡 Payment Request API Response: $success");
if (success == true) {
logSafe("✅ Payment request created successfully.");
return true;
} else {
return _errorSnackbar("Failed to create payment request.");
}
} catch (e, st) {
logSafe("💥 Submit Payment Request Error: $e\n$st",
level: LogLevel.error);
return _errorSnackbar("Something went wrong. Please try again later.");
} finally {
isSubmitting.value = false;
}
}
/// Form validation
bool _validateForm() {
if (selectedProject.value == null ||
selectedProject.value!['id'].toString().isEmpty)
return _errorSnackbar("Please select a project");
if (selectedCategory.value == null)
return _errorSnackbar("Please select a category");
if (selectedPayee.value.isEmpty)
return _errorSnackbar("Please select a payee");
if (selectedCurrency.value == null)
return _errorSnackbar("Please select currency");
return true;
}
bool _errorSnackbar(String msg, [String title = "Error"]) {
showAppSnackbar(title: title, message: msg, type: SnackbarType.error);
return false;
}
/// Clear form
void clearForm() {
titleController.clear();
dueDateController.clear();
amountController.clear();
descriptionController.clear();
selectedProject.value = null;
selectedCategory.value = null;
selectedPayee.value = '';
selectedCurrency.value = null;
isAdvancePayment.value = false;
attachments.clear();
existingAttachments.clear();
}
}

View File

@ -0,0 +1,128 @@
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/finance/payment_request_list_model.dart';
import 'package:marco/model/finance/payment_request_filter.dart';
import 'package:marco/helpers/services/app_logger.dart';
class PaymentRequestController extends GetxController {
// ---------------- Observables ----------------
final RxList<PaymentRequest> paymentRequests = <PaymentRequest>[].obs;
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
final RxBool isFilterApplied = false.obs;
// ---------------- Pagination ----------------
int _pageSize = 20;
int _pageNumber = 1;
bool _hasMoreData = true;
// ---------------- Filters ----------------
RxMap<String, dynamic> appliedFilter = <String, dynamic>{}.obs;
RxString searchString = ''.obs;
// ---------------- Filter Options ----------------
RxList<IdNameModel> projects = <IdNameModel>[].obs;
RxList<IdNameModel> payees = <IdNameModel>[].obs;
RxList<IdNameModel> categories = <IdNameModel>[].obs;
RxList<IdNameModel> currencies = <IdNameModel>[].obs;
RxList<IdNameModel> statuses = <IdNameModel>[].obs;
RxList<IdNameModel> createdBy = <IdNameModel>[].obs;
// ---------------- Fetch Filter Options ----------------
Future<void> fetchPaymentRequestFilterOptions() async {
try {
final response = await ApiService.getExpensePaymentRequestFilterApi();
if (response != null) {
projects.assignAll(response.data.projects);
payees.assignAll(response.data.payees);
categories.assignAll(response.data.expenseCategory);
currencies.assignAll(response.data.currency);
statuses.assignAll(response.data.status);
createdBy.assignAll(response.data.createdBy);
} else {
logSafe("Payment request filter API returned null",
level: LogLevel.warning);
}
} catch (e, stack) {
logSafe("Exception in fetchPaymentRequestFilterOptions: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
}
// ---------------- Fetch Payment Requests ----------------
Future<void> fetchPaymentRequests({int pageSize = 20}) async {
isLoading.value = true;
errorMessage.value = '';
_pageNumber = 1;
_pageSize = pageSize;
_hasMoreData = true;
paymentRequests.clear();
await _fetchPaymentRequestsFromApi();
isLoading.value = false;
}
// ---------------- Load More ----------------
Future<void> loadMorePaymentRequests() async {
if (isLoading.value || !_hasMoreData) return;
_pageNumber += 1;
isLoading.value = true;
await _fetchPaymentRequestsFromApi();
isLoading.value = false;
}
// ---------------- Internal API Call ----------------
Future<void> _fetchPaymentRequestsFromApi() async {
try {
final response = await ApiService.getExpensePaymentRequestListApi(
pageSize: _pageSize,
pageNumber: _pageNumber,
filter: appliedFilter,
searchString: searchString.value,
);
if (response != null && response.data.data.isNotEmpty) {
if (_pageNumber == 1) {
// First page, replace the list
paymentRequests.assignAll(response.data.data);
} else {
// Insert new data at the top for latest first
paymentRequests.insertAll(0, response.data.data);
}
} else {
if (_pageNumber == 1) {
errorMessage.value = 'No payment requests found.';
} else {
_hasMoreData = false;
}
}
} catch (e, stack) {
errorMessage.value = 'Failed to fetch payment requests.';
logSafe("Exception in _fetchPaymentRequestsFromApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
}
// ---------------- Filter Management ----------------
void setFilterApplied(bool applied) {
isFilterApplied.value = applied;
}
void applyFilter(Map<String, dynamic> filter, {String search = ''}) {
appliedFilter.assignAll(filter);
searchString.value = search;
isFilterApplied.value = filter.isNotEmpty || search.isNotEmpty;
fetchPaymentRequests();
}
void clearFilter() {
appliedFilter.clear();
searchString.value = '';
isFilterApplied.value = false;
fetchPaymentRequests();
}
}

View File

@ -0,0 +1,363 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/model/finance/payment_request_details_model.dart';
import 'package:marco/model/expense/payment_types_model.dart';
import 'package:marco/helpers/widgets/time_stamp_image_helper.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart';
import 'package:mime/mime.dart';
class PaymentRequestDetailController extends GetxController {
final Rx<PaymentRequestData?> paymentRequest = Rx<PaymentRequestData?>(null);
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
final RxList<PaymentModeModel> paymentModes = <PaymentModeModel>[].obs;
// Employee selection
final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null);
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
final RxList<EmployeeModel> employeeSearchResults = <EmployeeModel>[].obs;
final TextEditingController employeeSearchController =
TextEditingController();
final RxBool isSearchingEmployees = false.obs;
// Attachments
final RxList<File> attachments = <File>[].obs;
final RxList<Map<String, dynamic>> existingAttachments =
<Map<String, dynamic>>[].obs;
final isProcessingAttachment = false.obs;
// Payment mode
final selectedPaymentMode = Rxn<PaymentModeModel>();
// Text controllers for form
final TextEditingController locationController = TextEditingController();
final TextEditingController gstNumberController = TextEditingController();
// Form submission state
final RxBool isSubmitting = false.obs;
late String _requestId;
bool _isInitialized = false;
RxBool paymentSheetOpened = false.obs;
final ImagePicker _picker = ImagePicker();
/// Initialize controller
void init(String requestId) {
if (_isInitialized) return;
_isInitialized = true;
_requestId = requestId;
// Fetch payment request details + employees concurrently
Future.wait([
fetchPaymentRequestDetail(),
fetchAllEmployees(),
fetchPaymentModes(),
]);
}
/// Generic API wrapper for error handling
Future<T?> _apiCallWrapper<T>(
Future<T?> Function() apiCall, String operationName) async {
isLoading.value = true;
errorMessage.value = '';
try {
final result = await apiCall();
return result;
} catch (e) {
errorMessage.value = 'Error during $operationName: $e';
showAppSnackbar(
title: 'Error',
message: errorMessage.value,
type: SnackbarType.error);
return null;
} finally {
isLoading.value = false;
}
}
/// Fetch payment request details
Future<void> fetchPaymentRequestDetail() async {
isLoading.value = true;
try {
final response =
await ApiService.getExpensePaymentRequestDetailApi(_requestId);
if (response != null) {
paymentRequest.value = response.data;
} else {
errorMessage.value = "Failed to fetch payment request details";
showAppSnackbar(
title: 'Error',
message: errorMessage.value,
type: SnackbarType.error,
);
}
} catch (e) {
errorMessage.value = "Error fetching payment request details: $e";
showAppSnackbar(
title: 'Error',
message: errorMessage.value,
type: SnackbarType.error,
);
} finally {
isLoading.value = false;
}
}
/// Pick files from gallery or file picker
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) {
isProcessingAttachment.value = true;
File imageFile = File(pickedFile.path);
File timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: imageFile,
);
attachments.add(timestampedFile);
attachments.refresh();
}
} catch (e) {
_errorSnackbar("Camera error: $e");
} finally {
isProcessingAttachment.value = false;
}
}
// --- Location ---
final RxBool isFetchingLocation = false.obs;
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;
}
/// 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)));
} catch (e) {
errorMessage.value = 'Failed to parse employee data: $e';
showAppSnackbar(
title: 'Error',
message: errorMessage.value,
type: SnackbarType.error);
}
} else {
allEmployees.clear();
}
}
/// Fetch payment modes
Future<void> fetchPaymentModes() async {
isLoading.value = true;
try {
final paymentModesData = await ApiService.getMasterPaymentModes();
if (paymentModesData is List) {
paymentModes.value =
paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList();
} else {
paymentModes.clear();
showAppSnackbar(
title: 'Error',
message: 'Failed to fetch payment modes',
type: SnackbarType.error);
}
} catch (e) {
paymentModes.clear();
showAppSnackbar(
title: 'Error',
message: 'Error fetching payment modes: $e',
type: SnackbarType.error);
} finally {
isLoading.value = false;
}
}
/// Search employees
Future<void> searchEmployees(String query) async {
if (query.trim().isEmpty) {
employeeSearchResults.clear();
return;
}
isSearchingEmployees.value = true;
try {
final data =
await ApiService.searchEmployeesBasic(searchString: query.trim());
employeeSearchResults.assignAll(
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
);
} catch (e) {
employeeSearchResults.clear();
} finally {
isSearchingEmployees.value = false;
}
}
/// Update payment request status
Future<bool> updatePaymentRequestStatus({
required String statusId,
required String comment,
String? paidTransactionId,
String? paidById,
DateTime? paidAt,
double? baseAmount,
double? taxAmount,
String? tdsPercentage,
}) async {
isLoading.value = true;
try {
final success = await ApiService.updateExpensePaymentRequestStatusApi(
paymentRequestId: _requestId,
statusId: statusId,
comment: comment,
paidTransactionId: paidTransactionId,
paidById: paidById,
paidAt: paidAt,
baseAmount: baseAmount,
taxAmount: taxAmount,
tdsPercentage: tdsPercentage,
);
if (success) {
showAppSnackbar(
title: 'Success',
message: 'Payment submitted successfully',
type: SnackbarType.success);
await fetchPaymentRequestDetail();
} else {
showAppSnackbar(
title: 'Error',
message: 'Failed to update status. Please try again.',
type: SnackbarType.error);
}
return success;
} catch (e) {
showAppSnackbar(
title: 'Error',
message: 'Something went wrong: $e',
type: SnackbarType.error);
return false;
} finally {
isLoading.value = false;
}
}
// --- Snackbar Helper ---
void _errorSnackbar(String msg, [String title = "Error"]) {
showAppSnackbar(title: title, message: msg, type: SnackbarType.error);
}
// --- Payment Mode Selection ---
void selectPaymentMode(PaymentModeModel mode) {
selectedPaymentMode.value = mode;
}
// --- Submit Expense ---
Future<bool> submitExpense() async {
if (selectedPaymentMode.value == null) return false;
isSubmitting.value = true;
try {
// Prepare attachments with all required fields
final attachmentsPayload = attachments.map((file) {
final bytes = file.readAsBytesSync();
final mimeType =
lookupMimeType(file.path) ?? 'application/octet-stream';
return {
"fileName": file.path.split('/').last,
"base64Data": base64Encode(bytes),
"contentType": mimeType,
"description": "",
"fileSize": bytes.length,
"isActive": true,
};
}).toList();
// Call API
return await ApiService.createExpenseForPRApi(
paymentModeId: selectedPaymentMode.value!.id,
location: locationController.text,
gstNumber: gstNumberController.text,
paymentRequestId: _requestId,
billAttachments: attachmentsPayload,
);
} finally {
isSubmitting.value = false;
}
}
}

View File

@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/services/app_logger.dart';
class ProjectStatus {
final String id;
final String name;
ProjectStatus({required this.id, required this.name});
}
class CreateProjectController extends GetxController {
// Observables
var isSubmitting = false.obs;
var statusList = <ProjectStatus>[].obs;
ProjectStatus? selectedStatus;
/// Text controllers for form fields
final nameCtrl = TextEditingController();
final shortNameCtrl = TextEditingController();
final addressCtrl = TextEditingController();
final contactCtrl = TextEditingController();
final startDateCtrl = TextEditingController();
final endDateCtrl = TextEditingController();
@override
void onInit() {
super.onInit();
loadHardcodedStatuses();
}
/// Hardcoded project statuses
void loadHardcodedStatuses() {
final List<ProjectStatus> statuses = [
ProjectStatus(
id: "b74da4c2-d07e-46f2-9919-e75e49b12731", name: "Active"),
ProjectStatus(
id: "cdad86aa-8a56-4ff4-b633-9c629057dfef", name: "In Progress"),
ProjectStatus(
id: "603e994b-a27f-4e5d-a251-f3d69b0498ba", name: "On Hold"),
ProjectStatus(
id: "ef1c356e-0fe0-42df-a5d3-8daee355492d", name: "In Active"),
ProjectStatus(
id: "33deaef9-9af1-4f2a-b443-681ea0d04f81", name: "Completed"),
];
statusList.assignAll(statuses);
}
/// Create project API call using ApiService
Future<bool> createProject({
required String name,
required String projectAddress,
required String shortName,
required String contactPerson,
required DateTime startDate,
required DateTime endDate,
required String projectStatusId,
}) async {
try {
isSubmitting.value = true;
final success = await ApiService.createProjectApi(
name: name,
projectAddress: projectAddress,
shortName: shortName,
contactPerson: contactPerson,
startDate: startDate,
endDate: endDate,
projectStatusId: projectStatusId,
);
if (success) {
showAppSnackbar(
title: "Success",
message: "Project created successfully",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to create project",
type: SnackbarType.error,
);
}
return success;
} catch (e, stack) {
logSafe("Create project 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 {
isSubmitting.value = false;
}
}
@override
void onClose() {
nameCtrl.dispose();
shortNameCtrl.dispose();
addressCtrl.dispose();
contactCtrl.dispose();
startDateCtrl.dispose();
endDateCtrl.dispose();
super.onClose();
}
}

View File

@ -1,152 +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/dailyTaskPlanning/master_work_category_model.dart';
class AddTaskController extends GetxController {
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
MyFormValidator basicValidator = MyFormValidator();
RxnString selectedCategoryId = RxnString();
RxnString selectedCategoryName = RxnString();
var categoryIdNameMap = <String, String>{}.obs;
List<Map<String, dynamic>> roles = [];
RxnString selectedRoleId = RxnString();
RxBool isLoadingWorkMasterCategories = false.obs;
RxList<WorkCategoryModel> workMasterCategories = <WorkCategoryModel>[].obs;
RxBool isLoading = false.obs;
@override
void onInit() {
super.onInit();
fetchWorkMasterCategories();
}
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;
}
Future<bool> assignDailyTask({
required String workItemId,
required int plannedTask,
required String description,
required List<String> taskTeam,
DateTime? assignmentDate,
}) async {
logSafe("Starting task assignment...", 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<bool> createTask({
required String parentTaskId,
required String workAreaId,
required String activityId,
required int plannedTask,
required String comment,
required String categoryId,
DateTime? assignmentDate,
}) async {
logSafe("Creating new task...", level: LogLevel.info);
final response = await ApiService.createTask(
parentTaskId: parentTaskId,
plannedTask: plannedTask,
comment: comment,
workAreaId: workAreaId,
activityId: activityId,
assignmentDate: assignmentDate,
categoryId: categoryId,
);
if (response == true) {
logSafe("Task created successfully.", level: LogLevel.info);
showAppSnackbar(
title: "Success",
message: "Task created successfully!",
type: SnackbarType.success,
);
return true;
} else {
logSafe("Failed to create task.", level: LogLevel.error);
showAppSnackbar(
title: "Error",
message: "Failed to create task.",
type: SnackbarType.error,
);
return false;
}
}
Future<void> fetchWorkMasterCategories() async {
isLoadingWorkMasterCategories.value = true;
try {
final response = await ApiService.getMasterWorkCategories();
if (response != null) {
final dataList = response['data'] ?? [];
final parsedList = List<WorkCategoryModel>.from(
dataList.map((e) => WorkCategoryModel.fromJson(e)),
);
workMasterCategories.assignAll(parsedList);
final mapped = {for (var item in parsedList) item.id: item.name};
categoryIdNameMap.assignAll(mapped);
logSafe("Work categories fetched: ${dataList.length}", level: LogLevel.info);
} else {
logSafe("No work categories found or API call failed.", level: LogLevel.warning);
}
} catch (e, st) {
logSafe("Error parsing work categories", level: LogLevel.error, error: e, stackTrace: st);
workMasterCategories.clear();
categoryIdNameMap.clear();
}
isLoadingWorkMasterCategories.value = false;
update();
}
void selectCategory(String id) {
selectedCategoryId.value = id;
selectedCategoryName.value = categoryIdNameMap[id];
logSafe("Category selected", level: LogLevel.debug, );
}
}

View File

@ -1,146 +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';
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;
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,
);
}
Future<void> fetchTaskData(
String projectId, {
List<String>? serviceIds,
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;
}
final response = await ApiService.getDailyTasks(
projectId,
dateFrom: startDateTask,
dateTo: endDateTask,
serviceIds: serviceIds,
pageNumber: pageNumber,
pageSize: pageSize,
);
if (response != null && response.isNotEmpty) {
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();
currentPage = pageNumber;
} else {
hasMore = false;
}
isLoading.value = false;
isLoadingMore.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,279 +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 = [];
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 = [];
RxBool isAssigningTask = false.obs;
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 {
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,
);
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;
}
}
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;
}
}
/// Fetch Infra details and then tasks per work area
Future<void> fetchTaskData(String? projectId, {String? serviceId}) async {
if (projectId == null) {
logSafe("Project ID is null", level: LogLevel.warning);
return;
}
isLoading.value = true;
try {
// Fetch infra details
final infraResponse = await ApiService.getInfraDetails(projectId);
final infraData = infraResponse?['data'] as List<dynamic>?;
if (infraData == null || infraData.isEmpty) {
logSafe("No infra data found for project $projectId",
level: LogLevel.warning);
dailyTasks = [];
return;
}
// Map infra to dailyTasks structure
dailyTasks = infraData.map((buildingJson) {
final building = Building(
id: buildingJson['id'],
name: buildingJson['buildingName'],
description: buildingJson['description'],
floors: (buildingJson['floors'] as List<dynamic>).map((floorJson) {
return Floor(
id: floorJson['id'],
floorName: floorJson['floorName'],
workAreas:
(floorJson['workAreas'] as List<dynamic>).map((areaJson) {
return WorkArea(
id: areaJson['id'],
areaName: areaJson['areaName'],
workItems: [], // Will fill after tasks API
);
}).toList(),
);
}).toList(),
);
return TaskPlanningDetailsModel(
id: building.id,
name: building.name,
projectAddress: "",
contactPerson: "",
startDate: DateTime.now(),
endDate: DateTime.now(),
projectStatusId: "",
buildings: [building],
);
}).toList();
// Fetch tasks for each work area, passing serviceId only if selected
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, // <-- only pass if not null
);
final taskData = taskResponse?['data'] as List<dynamic>? ?? [];
area.workItems.addAll(taskData.map((taskJson) {
return 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);
}
}));
logSafe("Fetched infra and tasks for project $projectId",
level: LogLevel.info);
} 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

@ -1,296 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
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/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';
enum ApiStatus { idle, loading, success, failure }
class ReportTaskActionController extends MyController {
final RxBool isLoading = false.obs;
final Rx<ApiStatus> reportStatus = ApiStatus.idle.obs;
final Rx<ApiStatus> commentStatus = ApiStatus.idle.obs;
final RxList<File> selectedImages = <File>[].obs;
final RxList<WorkStatus> workStatus = <WorkStatus>[].obs;
final RxList<WorkStatus> workStatuses = <WorkStatus>[].obs;
final RxBool showAddTaskCheckbox = false.obs;
final RxBool isAddTaskChecked = false.obs;
final RxBool isLoadingWorkStatus = false.obs;
final Rxn<Map<String, dynamic>> selectedTask = Rxn<Map<String, dynamic>>();
final RxString selectedWorkStatusName = ''.obs;
final MyFormValidator basicValidator = MyFormValidator();
final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController());
final ImagePicker _picker = ImagePicker();
final assignedDateController = TextEditingController();
final workAreaController = TextEditingController();
final activityController = TextEditingController();
final teamSizeController = TextEditingController();
final taskIdController = TextEditingController();
final assignedController = TextEditingController();
final completedWorkController = TextEditingController();
final commentController = TextEditingController();
final assignedByController = TextEditingController();
final teamMembersController = TextEditingController();
final plannedWorkController = TextEditingController();
final approvedTaskController = TextEditingController();
List<TextEditingController> get _allControllers => [
assignedDateController,
workAreaController,
activityController,
teamSizeController,
taskIdController,
assignedController,
completedWorkController,
commentController,
assignedByController,
teamMembersController,
plannedWorkController,
approvedTaskController,
];
@override
void onInit() {
super.onInit();
logSafe("Initializing ReportTaskController...");
_initializeFormFields();
}
@override
void onClose() {
for (final controller in _allControllers) {
controller.dispose();
}
logSafe("Disposed all text controllers in ReportTaskActionController.");
super.onClose();
}
void _initializeFormFields() {
basicValidator
..addField('assigned_date', label: "Assigned Date", controller: assignedDateController)
..addField('work_area', label: "Work Area", controller: workAreaController)
..addField('activity', label: "Activity", controller: activityController)
..addField('team_size', label: "Team Size", controller: teamSizeController)
..addField('task_id', label: "Task Id", controller: taskIdController)
..addField('assigned', label: "Assigned", controller: assignedController)
..addField('completed_work', label: "Completed Work", required: true, controller: completedWorkController)
..addField('comment', label: "Comment", required: true, controller: commentController)
..addField('assigned_by', label: "Assigned By", controller: assignedByController)
..addField('team_members', label: "Team Members", controller: teamMembersController)
..addField('planned_work', label: "Planned Work", controller: plannedWorkController)
..addField('approved_task', label: "Approved Task", required: true, controller: approvedTaskController);
}
Future<bool> approveTask({
required String projectId,
required String comment,
required String reportActionId,
required String approvedTaskCount,
List<File>? images,
}) async {
logSafe("approveTask() started", sensitive: false);
if (projectId.isEmpty || reportActionId.isEmpty) {
_showError("Project ID and Report Action ID are required.");
logSafe("Missing required projectId or reportActionId", level: LogLevel.warning);
return false;
}
final approvedTaskInt = int.tryParse(approvedTaskCount);
final completedWorkInt = int.tryParse(completedWorkController.text.trim());
if (approvedTaskInt == null) {
_showError("Invalid approved task count.");
logSafe("Invalid approvedTaskCount: $approvedTaskCount", level: LogLevel.warning);
return false;
}
if (completedWorkInt != null && approvedTaskInt > completedWorkInt) {
_showError("Approved task count cannot exceed completed work.");
logSafe("Validation failed: approved > completed", level: LogLevel.warning);
return false;
}
if (comment.trim().isEmpty) {
_showError("Comment is required.");
logSafe("Comment field is empty", level: LogLevel.warning);
return false;
}
try {
reportStatus.value = ApiStatus.loading;
isLoading.value = true;
logSafe("Calling _prepareImages() for approval...");
final imageData = await _prepareImages(images);
logSafe("Calling ApiService.approveTask()");
final success = await ApiService.approveTask(
id: projectId,
workStatus: reportActionId,
approvedTask: approvedTaskInt,
comment: comment,
images: imageData,
);
if (success) {
logSafe("Task approved successfully");
_showSuccess("Task approved successfully!");
await taskController.fetchTaskData(projectId);
return true;
} else {
logSafe("API returned failure on approveTask", level: LogLevel.error);
_showError("Failed to approve task.");
return false;
}
} catch (e, st) {
logSafe("Error in approveTask: $e", level: LogLevel.error, error: e, stackTrace: st);
_showError("An error occurred.");
return false;
} finally {
isLoading.value = false;
Future.delayed(const Duration(milliseconds: 500), () {
reportStatus.value = ApiStatus.idle;
});
}
}
Future<void> commentTask({
required String projectId,
required String comment,
List<File>? images,
}) async {
logSafe("commentTask() started", sensitive: false);
if (commentController.text.trim().isEmpty) {
_showError("Comment is required.");
logSafe("Comment field is empty", level: LogLevel.warning);
return;
}
try {
isLoading.value = true;
logSafe("Calling _prepareImages() for comment...");
final imageData = await _prepareImages(images);
logSafe("Calling ApiService.commentTask()");
final success = await ApiService.commentTask(
id: projectId,
comment: commentController.text.trim(),
images: imageData,
).timeout(const Duration(seconds: 30), onTimeout: () {
throw Exception("Request timed out.");
});
if (success) {
logSafe("Comment added successfully");
_showSuccess("Task commented successfully!");
await taskController.fetchTaskData(projectId);
} else {
logSafe("API returned failure on commentTask", level: LogLevel.error);
_showError("Failed to comment task.");
}
} catch (e, st) {
logSafe("Error in commentTask: $e", level: LogLevel.error, error: e, stackTrace: st);
_showError("An error occurred while commenting the task.");
} finally {
isLoading.value = false;
}
}
Future<void> fetchWorkStatuses() async {
logSafe("Fetching work statuses...");
isLoadingWorkStatus.value = true;
final response = await ApiService.getWorkStatus();
if (response != null) {
final model = WorkStatusResponseModel.fromJson(response);
workStatus.assignAll(model.data);
logSafe("Fetched ${model.data.length} work statuses");
} else {
logSafe("No work statuses found or API call failed", level: LogLevel.warning);
}
isLoadingWorkStatus.value = false;
update(['dashboard_controller']);
}
Future<List<Map<String, dynamic>>?> _prepareImages(List<File>? images) async {
if (images == null || images.isEmpty) {
logSafe("_prepareImages: No images selected.");
return null;
}
logSafe("_prepareImages: Compressing and encoding images...");
final results = await Future.wait(images.map((file) async {
final compressedBytes = await compressImageToUnder100KB(file);
if (compressedBytes == null) return null;
return {
"fileName": file.path.split('/').last,
"base64Data": base64Encode(compressedBytes),
"contentType": _getContentTypeFromFileName(file.path),
"fileSize": compressedBytes.lengthInBytes,
"description": "Image uploaded for task",
};
}));
logSafe("_prepareImages: Prepared ${results.whereType<Map<String, dynamic>>().length} images.");
return results.whereType<Map<String, dynamic>>().toList();
}
String _getContentTypeFromFileName(String fileName) {
final ext = fileName.split('.').last.toLowerCase();
return switch (ext) {
'jpg' || 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
'gif' => 'image/gif',
_ => 'application/octet-stream',
};
}
Future<void> pickImages({required bool fromCamera}) async {
logSafe("Opening image picker...");
if (fromCamera) {
final pickedFile = await _picker.pickImage(source: ImageSource.camera, imageQuality: 75);
if (pickedFile != null) {
selectedImages.add(File(pickedFile.path));
logSafe("Image added from camera: ${pickedFile.path}", );
}
} else {
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path)));
logSafe("${pickedFiles.length} images added from gallery.", );
}
}
void removeImageAt(int index) {
if (index >= 0 && index < selectedImages.length) {
logSafe("Removing image at index $index", );
selectedImages.removeAt(index);
}
}
void _showError(String message) => showAppSnackbar(
title: "Error", message: message, type: SnackbarType.error);
void _showSuccess(String message) => showAppSnackbar(
title: "Success", message: message, type: SnackbarType.success);
}

View File

@ -1,248 +0,0 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:marco/controller/my_controller.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package: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:image_picker/image_picker.dart';
import 'dart:io';
import 'dart:convert';
import 'package:marco/helpers/widgets/my_image_compressor.dart';
enum ApiStatus { idle, loading, success, failure }
final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController());
final ImagePicker _picker = ImagePicker();
class ReportTaskController extends MyController {
List<PlatformFile> files = [];
MyFormValidator basicValidator = MyFormValidator();
RxBool isLoading = false.obs;
Rx<ApiStatus> reportStatus = ApiStatus.idle.obs;
Rx<ApiStatus> commentStatus = ApiStatus.idle.obs;
RxList<File> selectedImages = <File>[].obs;
final assignedDateController = TextEditingController();
final workAreaController = TextEditingController();
final activityController = TextEditingController();
final teamSizeController = TextEditingController();
final taskIdController = TextEditingController();
final assignedController = TextEditingController();
final completedWorkController = TextEditingController();
final commentController = TextEditingController();
final assignedByController = TextEditingController();
final teamMembersController = TextEditingController();
final plannedWorkController = TextEditingController();
@override
void onInit() {
super.onInit();
logSafe("Initializing ReportTaskController...");
basicValidator
..addField('assigned_date', label: "Assigned Date", controller: assignedDateController)
..addField('work_area', label: "Work Area", controller: workAreaController)
..addField('activity', label: "Activity", controller: activityController)
..addField('team_size', label: "Team Size", controller: teamSizeController)
..addField('task_id', label: "Task Id", controller: taskIdController)
..addField('assigned', label: "Assigned", controller: assignedController)
..addField('completed_work', label: "Completed Work", required: true, controller: completedWorkController)
..addField('comment', label: "Comment", required: true, controller: commentController)
..addField('assigned_by', label: "Assigned By", controller: assignedByController)
..addField('team_members', label: "Team Members", controller: teamMembersController)
..addField('planned_work', label: "Planned Work", controller: plannedWorkController);
logSafe("Form fields initialized.");
}
@override
void onClose() {
[
assignedDateController,
workAreaController,
activityController,
teamSizeController,
taskIdController,
assignedController,
completedWorkController,
commentController,
assignedByController,
teamMembersController,
plannedWorkController,
].forEach((controller) => controller.dispose());
super.onClose();
}
Future<bool> reportTask({
required String projectId,
required String comment,
required int completedTask,
required List<Map<String, dynamic>> checklist,
required DateTime reportedDate,
List<File>? images,
}) async {
logSafe("Reporting task for projectId", );
final completedWork = completedWorkController.text.trim();
if (completedWork.isEmpty || int.tryParse(completedWork) == null || int.parse(completedWork) < 0) {
_showError("Completed work must be a positive number.");
return false;
}
final commentField = commentController.text.trim();
if (commentField.isEmpty) {
_showError("Comment is required.");
return false;
}
try {
reportStatus.value = ApiStatus.loading;
isLoading.value = true;
final imageData = await _prepareImages(images, "task report");
final success = await ApiService.reportTask(
id: projectId,
comment: commentField,
completedTask: int.parse(completedWork),
checkList: checklist,
images: imageData,
);
if (success) {
reportStatus.value = ApiStatus.success;
_showSuccess("Task reported successfully!");
await taskController.fetchTaskData(projectId);
return true;
} else {
reportStatus.value = ApiStatus.failure;
_showError("Failed to report task.");
return false;
}
} catch (e, s) {
logSafe("Exception while reporting task", level: LogLevel.error, error: e, stackTrace: s);
reportStatus.value = ApiStatus.failure;
_showError("An error occurred while reporting the task.");
return false;
} finally {
isLoading.value = false;
Future.delayed(const Duration(milliseconds: 500), () {
reportStatus.value = ApiStatus.idle;
});
}
}
Future<void> commentTask({
required String projectId,
required String comment,
List<File>? images,
}) async {
logSafe("Submitting comment for project", );
final commentField = commentController.text.trim();
if (commentField.isEmpty) {
_showError("Comment is required.");
return;
}
try {
isLoading.value = true;
final imageData = await _prepareImages(images, "task comment");
final success = await ApiService.commentTask(
id: projectId,
comment: commentField,
images: imageData,
).timeout(const Duration(seconds: 30), onTimeout: () {
logSafe("Task comment request timed out.", level: LogLevel.error);
throw Exception("Request timed out.");
});
if (success) {
_showSuccess("Task commented successfully!");
await taskController.fetchTaskData(projectId);
} else {
_showError("Failed to comment task.");
}
} catch (e, s) {
logSafe("Exception while commenting task", level: LogLevel.error, error: e, stackTrace: s);
_showError("An error occurred while commenting the task.");
} finally {
isLoading.value = false;
}
}
Future<List<Map<String, dynamic>>?> _prepareImages(List<File>? images, String context) async {
if (images == null || images.isEmpty) return null;
logSafe("Preparing images for $context upload...");
final results = await Future.wait(images.map((file) async {
try {
final compressed = await compressImageToUnder100KB(file);
if (compressed == null) return null;
return {
"fileName": file.path.split('/').last,
"base64Data": base64Encode(compressed),
"contentType": _getContentTypeFromFileName(file.path),
"fileSize": compressed.lengthInBytes,
"description": "Image uploaded for $context",
};
} catch (e) {
logSafe("Image processing failed: ${file.path}", level: LogLevel.warning, error: e);
return null;
}
}));
return results.whereType<Map<String, dynamic>>().toList();
}
String _getContentTypeFromFileName(String fileName) {
final ext = fileName.split('.').last.toLowerCase();
return switch (ext) {
'jpg' || 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
'gif' => 'image/gif',
_ => 'application/octet-stream',
};
}
Future<void> pickImages({required bool fromCamera}) async {
try {
if (fromCamera) {
final pickedFile = await _picker.pickImage(source: ImageSource.camera, imageQuality: 75);
if (pickedFile != null) {
selectedImages.add(File(pickedFile.path));
}
} else {
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path)));
}
logSafe("Images picked: ${selectedImages.length}", );
} catch (e) {
logSafe("Error picking images", level: LogLevel.warning, error: e);
}
}
void removeImageAt(int index) {
if (index >= 0 && index < selectedImages.length) {
selectedImages.removeAt(index);
logSafe("Removed image at index $index");
}
}
void _showError(String message) => showAppSnackbar(
title: "Error",
message: message,
type: SnackbarType.error,
);
void _showSuccess(String message) => showAppSnackbar(
title: "Success",
message: message,
type: SnackbarType.success,
);
}

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,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';
class TenantSelectionController extends GetxController {
final TenantService _tenantService = TenantService();
var tenants = <Tenant>[].obs;
var isLoading = false.obs;
@override
void onInit() {
super.onInit();
loadTenants();
}
/// Load tenants from API
Future<void> loadTenants({bool fromTenantSelectionScreen = false}) async {
try {
isLoading.value = true;
final data = await _tenantService.getTenants();
if (data != null) {
tenants.value = data.map((e) => Tenant.fromJson(e)).toList();
final recentTenantId = LocalStorage.getRecentTenantId();
// If user came from TenantSelectionScreen & recent tenant exists, auto-select
if (fromTenantSelectionScreen && recentTenantId != null) {
final tenantExists = tenants.any((t) => t.id == recentTenantId);
if (tenantExists) {
await onTenantSelected(recentTenantId);
return;
} else {
// if tenant is no longer valid, clear recentTenant
await LocalStorage.removeRecentTenantId();
}
}
// Auto-select if only one tenant
if (tenants.length == 1) {
await onTenantSelected(tenants.first.id);
}
} else {
tenants.clear();
logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning);
}
} catch (e, st) {
logSafe("❌ Exception in loadTenants",
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isLoading.value = false;
}
}
/// Select tenant
Future<void> onTenantSelected(String tenantId) async {
try {
isLoading.value = true;
final success = await _tenantService.selectTenant(tenantId);
if (success) {
logSafe("✅ Tenant selection successful: $tenantId");
// Store selected tenant in memory
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
TenantService.setSelectedTenant(selectedTenant);
// 🔥 Save in LocalStorage
await LocalStorage.setRecentTenantId(tenantId);
// Navigate to dashboard
Get.offAllNamed('/dashboard');
showAppSnackbar(
title: "Success",
message: "Organization selected successfully.",
type: SnackbarType.success,
);
} else {
logSafe("❌ Tenant selection failed for: $tenantId",
level: LogLevel.warning);
// Show error snackbar
showAppSnackbar(
title: "Error",
message: "Unable to select organization. Please try again.",
type: SnackbarType.error,
);
}
} catch (e, st) {
logSafe("❌ Exception in onTenantSelected",
level: LogLevel.error, error: e, stackTrace: st);
// Show error snackbar for exception
showAppSnackbar(
title: "Error",
message: "An unexpected error occurred while selecting organization.",
type: SnackbarType.error,
);
} finally {
isLoading.value = false;
}
}
}

View File

@ -1,15 +1,45 @@
class ApiEndpoints { class ApiEndpoints {
static const String baseUrl = "https://stageapi.marcoaiot.com/api"; // static const String baseUrl = "https://mapi.marcoaiot.com/api";
// static const String baseUrl = "https://ofwapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.onfieldwork.com/api";
// static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api"; static const String baseUrl = "https://devapi.marcoaiot.com/api";
// Finance Module API Endpoints
static const String getMasterCurrencies = "/Master/currencies/list";
static const String getMasterExpensesCategories =
"/Master/expenses-categories";
static const String getExpensePaymentRequestPayee =
"/Expense/payment-request/payee";
// Dashboard Module API Endpoints // Dashboard Module API Endpoints
static const String getDashboardAttendanceOverview = static const String getDashboardAttendanceOverview =
"/dashboard/attendance-overview"; "/dashboard/attendance-overview";
static const String createExpensePaymentRequest =
"/expense/payment-request/create";
static const String getExpensePaymentRequestList =
"/Expense/get/payment-requests/list";
static const String getExpensePaymentRequestDetails =
"/Expense/get/payment-request/details";
static const String getExpensePaymentRequestFilter =
"/Expense/payment-request/filter";
static const String updateExpensePaymentRequestStatus =
"/Expense/payment-request/action";
static const String createExpenseforPR =
"/Expense/payment-request/expense/create";
static const String getDashboardProjectProgress = "/dashboard/progression"; static const String getDashboardProjectProgress = "/dashboard/progression";
static const String getDashboardTasks = "/dashboard/tasks"; static const String getDashboardTasks = "/dashboard/tasks";
static const String getDashboardTeams = "/dashboard/teams"; static const String getDashboardTeams = "/dashboard/teams";
static const String getDashboardProjects = "/dashboard/projects"; static const String getDashboardProjects = "/dashboard/projects";
static const String getDashboardMonthlyExpenses =
"/Dashboard/expense/monthly";
static const String getExpenseTypeReport = "/Dashboard/expense/type";
static const String getPendingExpenses = "/Dashboard/expense/pendings";
///// Projects Module API Endpoints
static const String createProject = "/project";
// Attendance Module API Endpoints // Attendance Module API Endpoints
static const String getProjects = "/project/list"; static const String getProjects = "/project/list";

View File

@ -19,14 +19,22 @@ import 'package:marco/model/document/master_document_type_model.dart';
import 'package:marco/model/document/document_details_model.dart'; import 'package:marco/model/document/document_details_model.dart';
import 'package:marco/model/document/document_version_model.dart'; import 'package:marco/model/document/document_version_model.dart';
import 'package:marco/model/attendance/organization_per_project_list_model.dart'; import 'package:marco/model/attendance/organization_per_project_list_model.dart';
import 'package:marco/model/tenant/tenant_services_model.dart'; import 'package:marco/model/dashboard/pending_expenses_model.dart';
import 'package:marco/model/dashboard/expense_type_report_model.dart';
import 'package:marco/model/dashboard/monthly_expence_model.dart';
import 'package:marco/model/finance/expense_category_model.dart';
import 'package:marco/model/finance/currency_list_model.dart';
import 'package:marco/model/finance/payment_payee_request_model.dart';
import 'package:marco/model/finance/payment_request_list_model.dart';
import 'package:marco/model/finance/payment_request_filter.dart';
import 'package:marco/model/finance/payment_request_details_model.dart';
class ApiService { class ApiService {
static const bool enableLogs = true; static const bool enableLogs = true;
static const Duration extendedTimeout = Duration(seconds: 60); static const Duration extendedTimeout = Duration(seconds: 60);
static Future<String?> _getToken() async { static Future<String?> _getToken() async {
final token = await LocalStorage.getJwtToken(); final token = LocalStorage.getJwtToken();
if (token == null) { if (token == null) {
logSafe("No JWT token found. Logging out..."); logSafe("No JWT token found. Logging out...");
@ -39,7 +47,7 @@ class ApiService {
logSafe("Access token is expired. Attempting refresh..."); logSafe("Access token is expired. Attempting refresh...");
final refreshed = await AuthService.refreshToken(); final refreshed = await AuthService.refreshToken();
if (refreshed) { if (refreshed) {
return await LocalStorage.getJwtToken(); return LocalStorage.getJwtToken();
} else { } else {
logSafe("Token refresh failed. Logging out immediately..."); logSafe("Token refresh failed. Logging out immediately...");
await LocalStorage.logout(); await LocalStorage.logout();
@ -56,7 +64,7 @@ class ApiService {
"Access token is about to expire in ${difference.inSeconds}s. Refreshing..."); "Access token is about to expire in ${difference.inSeconds}s. Refreshing...");
final refreshed = await AuthService.refreshToken(); final refreshed = await AuthService.refreshToken();
if (refreshed) { if (refreshed) {
return await LocalStorage.getJwtToken(); return LocalStorage.getJwtToken();
} else { } else {
logSafe("Token refresh failed (near expiry). Logging out..."); logSafe("Token refresh failed (near expiry). Logging out...");
await LocalStorage.logout(); await LocalStorage.logout();
@ -289,6 +297,547 @@ class ApiService {
} }
} }
/// Create Expense for Payment Request
static Future<bool> createExpenseForPRApi({
required String paymentModeId,
required String location,
required String gstNumber,
required String paymentRequestId,
List<Map<String, dynamic>> billAttachments = const [],
}) async {
const endpoint = ApiEndpoints.createExpenseforPR;
final body = {
"paymentModeId": paymentModeId,
"location": location,
"gstNumber": gstNumber,
"paymentRequestId": paymentRequestId,
"billAttachments": billAttachments,
};
try {
final response = await _postRequest(endpoint, body);
if (response == null) {
logSafe("Create Expense for PR failed: null response",
level: LogLevel.error);
return false;
}
logSafe("Create Expense for PR response status: ${response.statusCode}");
logSafe("Create Expense for PR response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe(
"Expense for Payment Request created successfully: ${json['data']}");
return true;
} else {
logSafe(
"Failed to create Expense for Payment Request: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
return false;
}
} catch (e, stack) {
logSafe("Exception during createExpenseForPRApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return false;
}
}
/// Update Expense Payment Request Status
static Future<bool> updateExpensePaymentRequestStatusApi({
required String paymentRequestId,
required String statusId,
required String comment,
String? paidTransactionId,
String? paidById,
DateTime? paidAt,
double? baseAmount,
double? taxAmount,
String? tdsPercentage,
}) async {
const endpoint = ApiEndpoints.updateExpensePaymentRequestStatus;
logSafe("Updating Payment Request Status for ID: $paymentRequestId");
final body = {
"paymentRequestId": paymentRequestId,
"statusId": statusId,
"comment": comment,
"paidTransactionId": paidTransactionId,
"paidById": paidById,
"paidAt": paidAt?.toIso8601String(),
"baseAmount": baseAmount,
"taxAmount": taxAmount,
"tdsPercentage": tdsPercentage ?? "0",
};
try {
final response = await _postRequest(endpoint, body);
if (response == null) {
logSafe("Update Payment Request Status failed: null response",
level: LogLevel.error);
return false;
}
logSafe(
"Update Payment Request Status response: ${response.statusCode} -> ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Payment Request status updated successfully!");
return true;
} else {
logSafe(
"Failed to update Payment Request Status: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
return false;
}
} catch (e, stack) {
logSafe("Exception during updateExpensePaymentRequestStatusApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return false;
}
}
/// Get Expense Payment Request Detail by ID
static Future<PaymentRequestDetail?> getExpensePaymentRequestDetailApi(
String paymentRequestId) async {
final endpoint =
"${ApiEndpoints.getExpensePaymentRequestDetails}/$paymentRequestId";
logSafe(
"Fetching Expense Payment Request Detail for ID: $paymentRequestId");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Expense Payment Request Detail request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(
response,
label: "Expense Payment Request Detail",
);
if (jsonResponse != null) {
return PaymentRequestDetail.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getExpensePaymentRequestDetailApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
static Future<PaymentRequestFilter?>
getExpensePaymentRequestFilterApi() async {
const endpoint = ApiEndpoints.getExpensePaymentRequestFilter;
logSafe("Fetching Expense Payment Request Filter");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Expense Payment Request Filter request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(
response,
label: "Expense Payment Request Filter",
);
if (jsonResponse != null) {
return PaymentRequestFilter.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getExpensePaymentRequestFilterApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get Expense Payment Request List
static Future<PaymentRequestResponse?> getExpensePaymentRequestListApi({
bool isActive = true,
int pageSize = 20,
int pageNumber = 1,
Map<String, dynamic>? filter,
String searchString = '',
}) async {
const endpoint = ApiEndpoints.getExpensePaymentRequestList;
logSafe("Fetching Expense Payment Request List");
try {
final queryParams = {
'isActive': isActive.toString(),
'pageSize': pageSize.toString(),
'pageNumber': pageNumber.toString(),
'filter': jsonEncode(filter ??
{
"projectIds": [],
"statusIds": [],
"createdByIds": [],
"currencyIds": [],
"expenseCategoryIds": [],
"payees": [],
"startDate": null,
"endDate": null
}),
'searchString': searchString,
};
final response = await _getRequest(endpoint, queryParams: queryParams);
if (response == null) {
logSafe("Expense Payment Request List request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(
response,
label: "Expense Payment Request List",
);
if (jsonResponse != null) {
return PaymentRequestResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getExpensePaymentRequestListApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Create Expense Payment Request (Project API style)
static Future<bool> createExpensePaymentRequestApi({
required String title,
required String projectId,
required String expenseCategoryId,
required String currencyId,
required String payee,
required double amount,
DateTime? dueDate,
required String description,
required bool isAdvancePayment,
List<Map<String, dynamic>> billAttachments = const [],
}) async {
const endpoint = ApiEndpoints.createExpensePaymentRequest;
final body = {
"title": title,
"projectId": projectId,
"expenseCategoryId": expenseCategoryId,
"currencyId": currencyId,
"payee": payee,
"amount": amount,
"dueDate": dueDate?.toIso8601String(),
"description": description,
"isAdvancePayment": isAdvancePayment,
"billAttachments": billAttachments,
};
try {
final response = await _postRequest(endpoint, body);
if (response == null) {
logSafe("Create Payment Request failed: null response",
level: LogLevel.error);
return false;
}
logSafe("Create Payment Request response status: ${response.statusCode}");
logSafe("Create Payment Request response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Payment Request created successfully: ${json['data']}");
return true;
} else {
logSafe(
"Failed to create Payment Request: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
return false;
}
} catch (e, stack) {
logSafe("Exception during createExpensePaymentRequestApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return false;
}
}
/// Get Master Currencies
static Future<CurrencyListResponse?> getMasterCurrenciesApi() async {
const endpoint = ApiEndpoints.getMasterCurrencies;
logSafe("Fetching Master Currencies");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Master Currencies request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse =
_parseResponseForAllData(response, label: "Master Currencies");
if (jsonResponse != null) {
return CurrencyListResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getMasterCurrenciesApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get Master Expense Categories
static Future<ExpenseCategoryResponse?>
getMasterExpenseCategoriesApi() async {
const endpoint = ApiEndpoints.getMasterExpensesCategories;
logSafe("Fetching Master Expense Categories");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Master Expense Categories request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(response,
label: "Master Expense Categories");
if (jsonResponse != null) {
return ExpenseCategoryResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getMasterExpenseCategoriesApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get Expense Payment Request Payee
static Future<PaymentRequestPayeeResponse?>
getExpensePaymentRequestPayeeApi() async {
const endpoint = ApiEndpoints.getExpensePaymentRequestPayee;
logSafe("Fetching Expense Payment Request Payees");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Expense Payment Request Payee request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(response,
label: "Expense Payment Request Payee");
if (jsonResponse != null) {
return PaymentRequestPayeeResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getExpensePaymentRequestPayeeApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get Monthly Expense Report (categoryId is optional)
static Future<DashboardMonthlyExpenseResponse?>
getDashboardMonthlyExpensesApi({
String? categoryId,
int months = 12,
}) async {
const endpoint = ApiEndpoints.getDashboardMonthlyExpenses;
logSafe("Fetching Dashboard Monthly Expenses for last $months months");
try {
final queryParams = {
'months': months.toString(),
if (categoryId != null && categoryId.isNotEmpty)
'categoryId': categoryId,
};
final response = await _getRequest(
endpoint,
queryParams: queryParams,
);
if (response == null) {
logSafe("Monthly Expense request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(response,
label: "Dashboard Monthly Expenses");
if (jsonResponse != null) {
return DashboardMonthlyExpenseResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getDashboardMonthlyExpensesApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get Expense Type Report
static Future<ExpenseTypeReportResponse?> getExpenseTypeReportApi({
required String projectId,
required DateTime startDate,
required DateTime endDate,
}) async {
const endpoint = ApiEndpoints.getExpenseTypeReport;
logSafe("Fetching Expense Type Report for projectId: $projectId");
try {
final response = await _getRequest(
endpoint,
queryParams: {
'projectId': projectId,
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
},
);
if (response == null) {
logSafe("Expense Type Report request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse =
_parseResponseForAllData(response, label: "Expense Type Report");
if (jsonResponse != null) {
return ExpenseTypeReportResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getExpenseTypeReportApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get Pending Expenses
static Future<PendingExpensesResponse?> getPendingExpensesApi({
required String projectId,
}) async {
const endpoint = ApiEndpoints.getPendingExpenses;
logSafe("Fetching Pending Expenses for projectId: $projectId");
try {
final response = await _getRequest(
endpoint,
queryParams: {'projectId': projectId},
);
if (response == null) {
logSafe("Pending Expenses request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse =
_parseResponseForAllData(response, label: "Pending Expenses");
if (jsonResponse != null) {
return PendingExpensesResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getPendingExpensesApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Create Project API
static Future<bool> createProjectApi({
required String name,
required String projectAddress,
required String shortName,
required String contactPerson,
required DateTime startDate,
required DateTime endDate,
required String projectStatusId,
}) async {
const endpoint = ApiEndpoints.createProject;
logSafe("Creating project: $name");
final Map<String, dynamic> payload = {
"name": name,
"projectAddress": projectAddress,
"shortName": shortName,
"contactPerson": contactPerson,
"startDate": startDate.toIso8601String(),
"endDate": endDate.toIso8601String(),
"projectStatusId": projectStatusId,
};
try {
final response =
await _postRequest(endpoint, payload, customTimeout: extendedTimeout);
if (response == null) {
logSafe("Create project failed: null response", level: LogLevel.error);
return false;
}
logSafe("Create project response status: ${response.statusCode}");
logSafe("Create project response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Project created successfully: ${json['data']}");
return true;
} else {
logSafe(
"Failed to create project: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
}
} catch (e, stack) {
logSafe("Exception during createProjectApi: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return false;
}
/// Get Organizations assigned to a Project /// Get Organizations assigned to a Project
static Future<OrganizationListResponse?> getAssignedOrganizations( static Future<OrganizationListResponse?> getAssignedOrganizations(
String projectId) async { String projectId) async {
@ -319,36 +868,6 @@ class ApiService {
return null; return null;
} }
//// Get Services assigned to a Project
static Future<ServiceListResponse?> getAssignedServices(
String projectId) async {
final endpoint = "${ApiEndpoints.getAssignedServices}/$projectId";
logSafe("Fetching services assigned to projectId: $projectId");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Assigned Services request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse =
_parseResponseForAllData(response, label: "Assigned Services");
if (jsonResponse != null) {
return ServiceListResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getAssignedServices: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
static Future<bool> postLogsApi(List<Map<String, dynamic>> logs) async { static Future<bool> postLogsApi(List<Map<String, dynamic>> logs) async {
const endpoint = "${ApiEndpoints.uploadLogs}"; const endpoint = "${ApiEndpoints.uploadLogs}";
logSafe("Posting logs... count=${logs.length}"); logSafe("Posting logs... count=${logs.length}");
@ -1761,19 +2280,18 @@ class ApiService {
return false; return false;
} }
static Future<List<dynamic>?> getDirectoryComments( static Future<List<dynamic>?> getDirectoryComments(
String contactId, { String contactId, {
bool active = true, bool active = true,
}) async { }) async {
final url = "${ApiEndpoints.getDirectoryNotes}/$contactId?active=$active"; final url = "${ApiEndpoints.getDirectoryNotes}/$contactId?active=$active";
final response = await _getRequest(url); final response = await _getRequest(url);
final data = response != null final data = response != null
? _parseResponse(response, label: 'Directory Comments') ? _parseResponse(response, label: 'Directory Comments')
: null; : null;
return data is List ? data : null;
}
return data is List ? data : null;
}
static Future<bool> updateContact( static Future<bool> updateContact(
String contactId, Map<String, dynamic> payload) async { String contactId, Map<String, dynamic> payload) async {

View File

@ -83,7 +83,7 @@ class AuthService {
logSafe("Login payload (raw): $data"); logSafe("Login payload (raw): $data");
logSafe("Login payload (JSON): ${jsonEncode(data)}"); logSafe("Login payload (JSON): ${jsonEncode(data)}");
final responseData = await _post("/auth/app/login", data); final responseData = await _post("/auth/login-mobile", data);
if (responseData == null) if (responseData == null)
return {"error": "Network error. Please check your connection."}; return {"error": "Network error. Please check your connection."};
@ -179,7 +179,7 @@ class AuthService {
if (employeeInfo == null) return null; if (employeeInfo == null) return null;
final token = await LocalStorage.getJwtToken(); final token = await LocalStorage.getJwtToken();
return _post( return _post(
"/auth/login-mpin", "/auth/login-mpin/v1",
{ {
"employeeId": employeeInfo.id, "employeeId": employeeInfo.id,
"mpin": mpin, "mpin": mpin,
@ -202,7 +202,7 @@ class AuthService {
required String email, required String email,
required String otp, required String otp,
}) async { }) async {
final data = await _post("/auth/login-otp", {"email": email, "otp": otp}); final data = await _post("/auth/login-otp/v1", {"email": email, "otp": otp});
if (data != null && data['data'] != null) { if (data != null && data['data'] != null) {
await _handleLoginSuccess(data['data']); await _handleLoginSuccess(data['data']);
return null; return null;

View File

@ -1,9 +1,6 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:marco/controller/attendance/attendance_screen_controller.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_screen_controller.dart';
import 'package:marco/controller/expense/expense_detail_controller.dart'; import 'package:marco/controller/expense/expense_detail_controller.dart';
import 'package:marco/controller/directory/directory_controller.dart'; import 'package:marco/controller/directory/directory_controller.dart';
@ -69,29 +66,8 @@ class NotificationActionHandler {
} }
break; break;
case 'Team_Modified': case 'Team_Modified':
// Call method to handle team modifications and dashboard update
_handleDashboardUpdate(data); _handleDashboardUpdate(data);
break; 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 /// 🔹 Expenses
case 'Expenses_Modified': case 'Expenses_Modified':
_handleExpenseUpdated(data); _handleExpenseUpdated(data);
@ -128,23 +104,6 @@ class NotificationActionHandler {
/// ---------------------- HANDLERS ---------------------- /// ---------------------- 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) { static bool _isAttendanceAction(String? action) {
const validActions = { const validActions = {
@ -159,13 +118,17 @@ class NotificationActionHandler {
} }
static void _handleExpenseUpdated(Map<String, dynamic> data) { static void _handleExpenseUpdated(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored expense update from another project.");
return;
}
final expenseId = data['ExpenseId']; final expenseId = data['ExpenseId'];
if (expenseId == null) { if (expenseId == null) {
_logger.w("⚠️ Expense update received without ExpenseId: $data"); _logger.w("⚠️ Expense update received without ExpenseId: $data");
return; return;
} }
// Update Expense List
_safeControllerUpdate<ExpenseController>( _safeControllerUpdate<ExpenseController>(
onFound: (controller) async { onFound: (controller) async {
await controller.fetchExpenses(); await controller.fetchExpenses();
@ -175,7 +138,6 @@ class NotificationActionHandler {
'✅ ExpenseController refreshed from expense notification.', '✅ ExpenseController refreshed from expense notification.',
); );
// Update Expense Detail (if open and matches this expenseId)
_safeControllerUpdate<ExpenseDetailController>( _safeControllerUpdate<ExpenseDetailController>(
onFound: (controller) async { onFound: (controller) async {
if (controller.expense.value?.id == expenseId) { if (controller.expense.value?.id == expenseId) {
@ -190,6 +152,11 @@ class NotificationActionHandler {
} }
static void _handleAttendanceUpdated(Map<String, dynamic> data) { static void _handleAttendanceUpdated(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored attendance update from another project.");
return;
}
_safeControllerUpdate<AttendanceController>( _safeControllerUpdate<AttendanceController>(
onFound: (controller) => controller.refreshDataFromNotification( onFound: (controller) => controller.refreshDataFromNotification(
projectId: data['ProjectId'], projectId: data['ProjectId'],
@ -199,25 +166,18 @@ class NotificationActionHandler {
); );
} }
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 ---------------------- /// ---------------------- DOCUMENT HANDLER ----------------------
static void _handleDocumentModified(Map<String, dynamic> data) { static void _handleDocumentModified(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored document update from another project.");
return;
}
String entityTypeId; String entityTypeId;
String entityId; String entityId;
String? documentId = data['DocumentId']; String? documentId = data['DocumentId'];
// Determine entity type and ID
if (data['Keyword'] == 'Employee_Document_Modified') { if (data['Keyword'] == 'Employee_Document_Modified') {
entityTypeId = Permissions.employeeEntity; entityTypeId = Permissions.employeeEntity;
entityId = data['EmployeeId'] ?? ''; entityId = data['EmployeeId'] ?? '';
@ -237,7 +197,6 @@ class NotificationActionHandler {
_logger.i( _logger.i(
"🔔 Document notification received: keyword=${data['Keyword']}, entityTypeId=$entityTypeId, entityId=$entityId, documentId=$documentId"); "🔔 Document notification received: keyword=${data['Keyword']}, entityTypeId=$entityTypeId, entityId=$entityId, documentId=$documentId");
// Refresh Document List
if (Get.isRegistered<DocumentController>()) { if (Get.isRegistered<DocumentController>()) {
_safeControllerUpdate<DocumentController>( _safeControllerUpdate<DocumentController>(
onFound: (controller) async { onFound: (controller) async {
@ -255,11 +214,9 @@ class NotificationActionHandler {
_logger.w('⚠️ DocumentController not registered, skipping list refresh.'); _logger.w('⚠️ DocumentController not registered, skipping list refresh.');
} }
// Refresh Document Details (if open)
if (documentId != null && Get.isRegistered<DocumentDetailsController>()) { if (documentId != null && Get.isRegistered<DocumentDetailsController>()) {
_safeControllerUpdate<DocumentDetailsController>( _safeControllerUpdate<DocumentDetailsController>(
onFound: (controller) async { onFound: (controller) async {
// Refresh details regardless of current document
await controller.fetchDocumentDetails(documentId); await controller.fetchDocumentDetails(documentId);
_logger.i( _logger.i(
"✅ DocumentDetailsController refreshed for Document $documentId"); "✅ DocumentDetailsController refreshed for Document $documentId");
@ -276,13 +233,10 @@ class NotificationActionHandler {
/// ---------------------- DIRECTORY HANDLERS ---------------------- /// ---------------------- DIRECTORY HANDLERS ----------------------
static void _handleContactModified(Map<String, dynamic> data) { static void _handleContactModified(Map<String, dynamic> data) {
final contactId = data['ContactId'];
// Always refresh the contact list
_safeControllerUpdate<DirectoryController>( _safeControllerUpdate<DirectoryController>(
onFound: (controller) { onFound: (controller) {
controller.fetchContacts(); controller.fetchContacts();
// If a specific contact is provided, refresh its notes as well final contactId = data['ContactId'];
if (contactId != null) { if (contactId != null) {
controller.fetchCommentsForContact(contactId); controller.fetchCommentsForContact(contactId);
} }
@ -293,7 +247,6 @@ class NotificationActionHandler {
'✅ Directory contacts (and notes if applicable) refreshed from notification.', '✅ Directory contacts (and notes if applicable) refreshed from notification.',
); );
// Refresh notes globally as well
_safeControllerUpdate<NotesController>( _safeControllerUpdate<NotesController>(
onFound: (controller) => controller.fetchNotes(), onFound: (controller) => controller.fetchNotes(),
notFoundMessage: '⚠️ NotesController not found, cannot refresh notes.', notFoundMessage: '⚠️ NotesController not found, cannot refresh notes.',
@ -302,7 +255,6 @@ class NotificationActionHandler {
} }
static void _handleContactNoteModified(Map<String, dynamic> data) { static void _handleContactNoteModified(Map<String, dynamic> data) {
// Refresh both contacts and notes when a note is modified
_handleContactModified(data); _handleContactModified(data);
} }
@ -324,6 +276,11 @@ class NotificationActionHandler {
/// ---------------------- DASHBOARD HANDLER ---------------------- /// ---------------------- DASHBOARD HANDLER ----------------------
static void _handleDashboardUpdate(Map<String, dynamic> data) { static void _handleDashboardUpdate(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored dashboard update from another project.");
return;
}
_safeControllerUpdate<DashboardController>( _safeControllerUpdate<DashboardController>(
onFound: (controller) async { onFound: (controller) async {
final type = data['type'] ?? ''; final type = data['type'] ?? '';
@ -347,11 +304,9 @@ class NotificationActionHandler {
controller.projectController.selectedProjectId.value; controller.projectController.selectedProjectId.value;
final projectIdsString = data['ProjectIds'] ?? ''; final projectIdsString = data['ProjectIds'] ?? '';
// Convert comma-separated string to List<String>
final notificationProjectIds = final notificationProjectIds =
projectIdsString.split(',').map((e) => e.trim()).toList(); projectIdsString.split(',').map((e) => e.trim()).toList();
// Refresh only if current project ID is in the list
if (notificationProjectIds.contains(currentProjectId)) { if (notificationProjectIds.contains(currentProjectId)) {
await controller.fetchDashboardTeams(projectId: currentProjectId); await controller.fetchDashboardTeams(projectId: currentProjectId);
} }
@ -375,6 +330,24 @@ class NotificationActionHandler {
/// ---------------------- UTILITY ---------------------- /// ---------------------- UTILITY ----------------------
static bool _isCurrentProject(Map<String, dynamic> data) {
try {
final dashboard = Get.find<DashboardController>();
final currentProjectId =
dashboard.projectController.selectedProjectId.value;
final notificationProjectId = data['ProjectId']?.toString();
if (notificationProjectId == null || notificationProjectId.isEmpty) {
return true; // No project info allow global refresh
}
return notificationProjectId == currentProjectId;
} catch (e) {
_logger.w("⚠️ Could not verify project context: $e");
return true;
}
}
static void _safeControllerUpdate<T>({ static void _safeControllerUpdate<T>({
required void Function(T controller) onFound, required void Function(T controller) onFound,
required String notFoundMessage, required String notFoundMessage,

View File

@ -1,152 +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");
}
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

@ -2,34 +2,9 @@ import 'package:flutter/material.dart';
import 'package:marco/helpers/theme/theme_customizer.dart'; import 'package:marco/helpers/theme/theme_customizer.dart';
enum LeftBarThemeType { light, dark } enum LeftBarThemeType { light, dark }
enum ContentThemeType { light, dark } enum ContentThemeType { light, dark }
enum RightBarThemeType { light, dark } enum RightBarThemeType { light, dark }
enum ContentThemeColor {
primary,
secondary,
success,
info,
warning,
danger,
light,
dark,
pink,
green,
red,
brandRed;
Color get color {
return (AdminTheme.theme.contentTheme.getMappedIntoThemeColor[this]?['color']) ?? Colors.black;
}
Color get onColor {
return (AdminTheme.theme.contentTheme.getMappedIntoThemeColor[this]?['onColor']) ?? Colors.white;
}
}
class LeftBarTheme { class LeftBarTheme {
final Color background, onBackground; final Color background, onBackground;
final Color labelColor; final Color labelColor;
@ -43,16 +18,15 @@ class LeftBarTheme {
this.activeItemBackground = const Color(0x15663399), this.activeItemBackground = const Color(0x15663399),
}); });
//-------------------------------------- Left Bar Theme ----------------------------------------//
static final LeftBarTheme lightLeftBarTheme = LeftBarTheme(); static final LeftBarTheme lightLeftBarTheme = LeftBarTheme();
static final LeftBarTheme darkLeftBarTheme = LeftBarTheme( static final LeftBarTheme darkLeftBarTheme = LeftBarTheme(
background: const Color(0xff282c32), background: const Color(0xff282c32),
onBackground: const Color(0xffdcdcdc), onBackground: const Color(0xffdcdcdc),
labelColor: const Color(0xff32BFAE), labelColor: const Color(0xff32BFAE),
activeItemBackground: const Color(0x1532BFAE), activeItemBackground: const Color(0x1532BFAE),
activeItemColor: const Color(0xff32BFAE)); activeItemColor: const Color(0xff32BFAE),
);
static LeftBarTheme getThemeFromType(LeftBarThemeType leftBarThemeType) { static LeftBarTheme getThemeFromType(LeftBarThemeType leftBarThemeType) {
switch (leftBarThemeType) { switch (leftBarThemeType) {
@ -73,11 +47,12 @@ class TopBarTheme {
this.onBackground = const Color(0xff313a46), this.onBackground = const Color(0xff313a46),
}); });
//-------------------------------------- Left Bar Theme ----------------------------------------//
static final TopBarTheme lightTopBarTheme = TopBarTheme(); static final TopBarTheme lightTopBarTheme = TopBarTheme();
static final TopBarTheme darkTopBarTheme = TopBarTheme(background: const Color(0xff2c3036), onBackground: const Color(0xffdcdcdc)); static final TopBarTheme darkTopBarTheme = TopBarTheme(
background: const Color(0xff2c3036),
onBackground: const Color(0xffdcdcdc),
);
} }
class RightBarTheme { class RightBarTheme {
@ -91,19 +66,41 @@ class RightBarTheme {
this.onDisabled = const Color(0xff313a46), this.onDisabled = const Color(0xff313a46),
}); });
//-------------------------------------- Left Bar Theme ----------------------------------------//
static final RightBarTheme lightRightBarTheme = RightBarTheme( static final RightBarTheme lightRightBarTheme = RightBarTheme(
disabled: const Color(0xffffffff), disabled: const Color(0xffffffff),
onDisabled: const Color(0xffdee2e6), onDisabled: const Color(0xffdee2e6),
activeSwitchBorderColor: const Color(0xff727cf5), activeSwitchBorderColor: const Color(0xff727cf5),
inactiveSwitchBorderColor: const Color(0xffdee2e6)); inactiveSwitchBorderColor: const Color(0xffdee2e6),
);
static final RightBarTheme darkRightBarTheme = RightBarTheme( static final RightBarTheme darkRightBarTheme = RightBarTheme(
disabled: const Color(0xff444d57), disabled: const Color(0xff444d57),
activeSwitchBorderColor: const Color(0xff727cf5), activeSwitchBorderColor: const Color(0xff727cf5),
inactiveSwitchBorderColor: const Color(0xffdee2e6), inactiveSwitchBorderColor: const Color(0xffdee2e6),
onDisabled: const Color(0xff515a65)); onDisabled: const Color(0xff515a65),
);
}
enum ContentThemeColor {
primary,
secondary,
success,
info,
warning,
danger,
light,
dark,
pink,
green,
red;
Color get color {
return (AdminTheme.theme.contentTheme.getMappedIntoThemeColor[this]?['color']) ?? Colors.black;
}
Color get onColor {
return (AdminTheme.theme.contentTheme.getMappedIntoThemeColor[this]?['onColor']) ?? Colors.white;
}
} }
class ContentTheme { class ContentTheme {
@ -120,29 +117,11 @@ class ContentTheme {
final Color purple, onPurple; final Color purple, onPurple;
final Color pink, onPink; final Color pink, onPink;
final Color red, onRed; final Color red, onRed;
final Color brandRed, onBrandRed;
final Color cardBackground, cardShadow, cardBorder, cardText, cardTextMuted; final Color cardBackground, cardShadow, cardBorder, cardText, cardTextMuted;
final Color title; final Color title;
final Color disabled, onDisabled; final Color disabled, onDisabled;
Map<ContentThemeColor, Map<String, Color>> get getMappedIntoThemeColor {
var c = AdminTheme.theme.contentTheme;
return {
ContentThemeColor.primary: {'color': c.primary, 'onColor': c.onPrimary},
ContentThemeColor.secondary: {'color': c.secondary, 'onColor': c.onSecondary},
ContentThemeColor.success: {'color': c.success, 'onColor': c.onSuccess},
ContentThemeColor.info: {'color': c.info, 'onColor': c.onInfo},
ContentThemeColor.warning: {'color': c.warning, 'onColor': c.onWarning},
ContentThemeColor.danger: {'color': c.danger, 'onColor': c.onDanger},
ContentThemeColor.light: {'color': c.light, 'onColor': c.onLight},
ContentThemeColor.dark: {'color': c.dark, 'onColor': c.onDark},
ContentThemeColor.pink: {'color': c.pink, 'onColor': c.onPink},
ContentThemeColor.red: {'color': c.red, 'onColor': c.onRed},
ContentThemeColor.brandRed: {'color': c.brandRed, 'onColor': c.onBrandRed},
};
}
ContentTheme({ ContentTheme({
this.background = const Color(0xfffafbfe), this.background = const Color(0xfffafbfe),
this.onBackground = const Color(0xffF1F1F2), this.onBackground = const Color(0xffF1F1F2),
@ -163,13 +142,11 @@ class ContentTheme {
this.dark = const Color(0xff313a46), this.dark = const Color(0xff313a46),
this.onDark = const Color(0xffffffff), this.onDark = const Color(0xffffffff),
this.purple = const Color(0xff800080), this.purple = const Color(0xff800080),
this.onPurple = const Color(0xffFF0000), this.onPurple = const Color(0xffffffff),
this.pink = const Color(0xffFF1087), this.pink = const Color(0xffff1087),
this.onPink = const Color(0xffffffff), this.onPink = const Color(0xffffffff),
this.red = const Color(0xffFF0000), this.red = const Color(0xffff0000),
this.onRed = const Color(0xffffffff), this.onRed = const Color(0xffffffff),
this.brandRed = const Color.fromARGB(255, 255, 0, 0),
this.onBrandRed = const Color(0xffffffff),
this.cardBackground = const Color(0xffffffff), this.cardBackground = const Color(0xffffffff),
this.cardShadow = const Color(0xffffffff), this.cardShadow = const Color(0xffffffff),
this.cardBorder = const Color(0xffffffff), this.cardBorder = const Color(0xffffffff),
@ -180,44 +157,103 @@ class ContentTheme {
this.onDisabled = const Color(0xffffffff), this.onDisabled = const Color(0xffffffff),
}); });
//-------------------------------------- Left Bar Theme ----------------------------------------// Map<ContentThemeColor, Map<String, Color>> get getMappedIntoThemeColor {
return {
ContentThemeColor.primary: {'color': primary, 'onColor': onPrimary},
ContentThemeColor.secondary: {'color': secondary, 'onColor': onSecondary},
ContentThemeColor.success: {'color': success, 'onColor': onSuccess},
ContentThemeColor.info: {'color': info, 'onColor': onInfo},
ContentThemeColor.warning: {'color': warning, 'onColor': onWarning},
ContentThemeColor.danger: {'color': danger, 'onColor': onDanger},
ContentThemeColor.light: {'color': light, 'onColor': onLight},
ContentThemeColor.dark: {'color': dark, 'onColor': onDark},
ContentThemeColor.pink: {'color': pink, 'onColor': onPink},
ContentThemeColor.red: {'color': red, 'onColor': onRed},
};
}
static final ContentTheme lightContentTheme = ContentTheme( ContentTheme copyWith({
primary: Color(0xff663399), Color? primary,
background: const Color(0xfffafbfe), Color? onPrimary,
onBackground: const Color(0xff313a46), Color? secondary,
cardBorder: const Color(0xffe8ecf1), Color? onSecondary,
cardBackground: const Color(0xffffffff), Color? background,
cardShadow: const Color(0xff9aa1ab), Color? onBackground,
cardText: const Color(0xff6c757d), }) {
title: const Color(0xff6c757d), return ContentTheme(
cardTextMuted: const Color(0xff98a6ad), primary: primary ?? this.primary,
brandRed: const Color.fromARGB(255, 255, 0, 0), onPrimary: onPrimary ?? this.onPrimary,
onBrandRed: const Color(0xffffffff), secondary: secondary ?? this.secondary,
); onSecondary: onSecondary ?? this.onSecondary,
background: background ?? this.background,
onBackground: onBackground ?? this.onBackground,
success: success,
onSuccess: onSuccess,
danger: danger,
onDanger: onDanger,
warning: warning,
onWarning: onWarning,
info: info,
onInfo: onInfo,
light: light,
onLight: onLight,
dark: dark,
onDark: onDark,
purple: purple,
onPurple: onPurple,
pink: pink,
onPink: onPink,
red: red,
onRed: onRed,
cardBackground: cardBackground,
cardShadow: cardShadow,
cardBorder: cardBorder,
cardText: cardText,
cardTextMuted: cardTextMuted,
title: title,
disabled: disabled,
onDisabled: onDisabled,
);
}
static final ContentTheme darkContentTheme = ContentTheme( static ContentTheme withColorTheme(
primary: Color(0xff32BFAE), ColorThemeType colorTheme, {
background: const Color(0xff343a40), ThemeMode mode = ThemeMode.light,
onBackground: const Color(0xffF1F1F2), }) {
disabled: const Color(0xff444d57), final baseTheme = mode == ThemeMode.light
onDisabled: const Color(0xff515a65), ? ContentTheme()
cardBorder: const Color(0xff464f5b), : ContentTheme(
cardBackground: const Color(0xff37404a), primary: const Color(0xff32BFAE),
cardShadow: const Color(0xff01030E), background: const Color(0xff343a40),
cardText: const Color(0xffaab8c5), onBackground: const Color(0xffF1F1F2),
title: const Color(0xffaab8c5), cardBorder: const Color(0xff464f5b),
cardTextMuted: const Color(0xff8391a2), cardBackground: const Color(0xff37404a),
brandRed: const Color.fromARGB(255, 255, 0, 0), cardShadow: const Color(0xff01030E),
onBrandRed: const Color(0xffffffff), cardText: const Color(0xffaab8c5),
); title: const Color(0xffaab8c5),
cardTextMuted: const Color(0xff8391a2),
);
switch (colorTheme) {
case ColorThemeType.purple:
return baseTheme.copyWith(primary: const Color(0xff663399), onPrimary: Colors.white);
case ColorThemeType.red:
return baseTheme.copyWith(primary: const Color(0xffff0000), onPrimary: Colors.white);
case ColorThemeType.green:
return baseTheme.copyWith(primary: const Color(0xff49BF3C), onPrimary: Colors.white);
case ColorThemeType.blue:
return baseTheme.copyWith(primary: const Color(0xff007bff), onPrimary: Colors.white);
}
}
} }
enum ColorThemeType { purple, red, green, blue }
class AdminTheme { class AdminTheme {
final ContentTheme contentTheme;
final LeftBarTheme leftBarTheme; final LeftBarTheme leftBarTheme;
final RightBarTheme rightBarTheme; final RightBarTheme rightBarTheme;
final TopBarTheme topBarTheme; final TopBarTheme topBarTheme;
final ContentTheme contentTheme;
AdminTheme({ AdminTheme({
required this.leftBarTheme, required this.leftBarTheme,
@ -226,19 +262,22 @@ class AdminTheme {
required this.contentTheme, required this.contentTheme,
}); });
//-------------------------------------- Left Bar Theme ----------------------------------------//
static AdminTheme theme = AdminTheme( static AdminTheme theme = AdminTheme(
leftBarTheme: LeftBarTheme.lightLeftBarTheme, leftBarTheme: LeftBarTheme.lightLeftBarTheme,
topBarTheme: TopBarTheme.lightTopBarTheme, topBarTheme: TopBarTheme.lightTopBarTheme,
rightBarTheme: RightBarTheme.lightRightBarTheme, rightBarTheme: RightBarTheme.lightRightBarTheme,
contentTheme: ContentTheme.lightContentTheme); contentTheme: ContentTheme.withColorTheme(ColorThemeType.green, mode: ThemeMode.light),
);
static void setTheme() { static void setTheme() {
final themeMode = ThemeCustomizer.instance.theme;
final colorTheme = ThemeCustomizer.instance.colorTheme;
theme = AdminTheme( theme = AdminTheme(
leftBarTheme: ThemeCustomizer.instance.theme == ThemeMode.dark ? LeftBarTheme.darkLeftBarTheme : LeftBarTheme.lightLeftBarTheme, leftBarTheme: themeMode == ThemeMode.dark ? LeftBarTheme.darkLeftBarTheme : LeftBarTheme.lightLeftBarTheme,
topBarTheme: ThemeCustomizer.instance.theme == ThemeMode.dark ? TopBarTheme.darkTopBarTheme : TopBarTheme.lightTopBarTheme, topBarTheme: themeMode == ThemeMode.dark ? TopBarTheme.darkTopBarTheme : TopBarTheme.lightTopBarTheme,
rightBarTheme: ThemeCustomizer.instance.theme == ThemeMode.dark ? RightBarTheme.darkRightBarTheme : RightBarTheme.lightRightBarTheme, rightBarTheme: themeMode == ThemeMode.dark ? RightBarTheme.darkRightBarTheme : RightBarTheme.lightRightBarTheme,
contentTheme: ThemeCustomizer.instance.theme == ThemeMode.dark ? ContentTheme.darkContentTheme : ContentTheme.lightContentTheme); contentTheme: ContentTheme.withColorTheme(colorTheme, mode: themeMode),
);
} }
} }

View File

@ -1,5 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:marco/helpers/services/json_decoder.dart'; import 'package:marco/helpers/services/json_decoder.dart';
import 'package:marco/helpers/services/localizations/language.dart'; import 'package:marco/helpers/services/localizations/language.dart';
import 'package:marco/helpers/services/localizations/translator.dart'; import 'package:marco/helpers/services/localizations/translator.dart';
@ -7,8 +7,8 @@ import 'package:marco/helpers/services/navigation_services.dart';
import 'package:marco/helpers/theme/admin_theme.dart'; import 'package:marco/helpers/theme/admin_theme.dart';
import 'package:marco/helpers/theme/app_notifier.dart'; import 'package:marco/helpers/theme/app_notifier.dart';
import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/theme/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
typedef ThemeChangeCallback = void Function( typedef ThemeChangeCallback = void Function(
ThemeCustomizer oldVal, ThemeCustomizer newVal); ThemeCustomizer oldVal, ThemeCustomizer newVal);
@ -24,7 +24,7 @@ class ThemeCustomizer {
ThemeMode leftBarTheme = ThemeMode.light; ThemeMode leftBarTheme = ThemeMode.light;
ThemeMode rightBarTheme = ThemeMode.light; ThemeMode rightBarTheme = ThemeMode.light;
ThemeMode topBarTheme = ThemeMode.light; ThemeMode topBarTheme = ThemeMode.light;
ColorThemeType colorTheme = ColorThemeType.green;
bool rightBarOpen = false; bool rightBarOpen = false;
bool leftBarCondensed = false; bool leftBarCondensed = false;
@ -33,6 +33,8 @@ class ThemeCustomizer {
static Future<void> init() async { static Future<void> init() async {
await initLanguage(); await initLanguage();
await _loadColorTheme();
_notify();
} }
static initLanguage() async { static initLanguage() async {
@ -40,7 +42,7 @@ class ThemeCustomizer {
} }
String toJSON() { String toJSON() {
return jsonEncode({'theme': theme.name}); return jsonEncode({'theme': theme.name, 'colorTheme': colorTheme.name});
} }
static ThemeCustomizer fromJSON(String? json) { static ThemeCustomizer fromJSON(String? json) {
@ -49,6 +51,8 @@ class ThemeCustomizer {
JSONDecoder decoder = JSONDecoder(json); JSONDecoder decoder = JSONDecoder(json);
instance.theme = instance.theme =
decoder.getEnum('theme', ThemeMode.values, ThemeMode.light); decoder.getEnum('theme', ThemeMode.values, ThemeMode.light);
instance.colorTheme = decoder.getEnum(
'colorTheme', ColorThemeType.values, ColorThemeType.red);
} }
return instance; return instance;
} }
@ -73,6 +77,11 @@ class ThemeCustomizer {
} }
} }
/// Public method to trigger theme updates externally
static void applyThemeChange() {
_notify();
}
static void notify() { static void notify() {
for (var value in _notifier) { for (var value in _notifier) {
value(oldInstance, instance); value(oldInstance, instance);
@ -112,12 +121,46 @@ class ThemeCustomizer {
tc.topBarTheme = topBarTheme; tc.topBarTheme = topBarTheme;
tc.rightBarOpen = rightBarOpen; tc.rightBarOpen = rightBarOpen;
tc.leftBarCondensed = leftBarCondensed; tc.leftBarCondensed = leftBarCondensed;
tc.colorTheme = colorTheme;
tc.currentLanguage = currentLanguage.clone(); tc.currentLanguage = currentLanguage.clone();
return tc; return tc;
} }
@override @override
String toString() { String toString() {
return 'ThemeCustomizer{theme: $theme}'; return 'ThemeCustomizer{theme: $theme, colorTheme: $colorTheme}';
}
// ---------------------------------------------------------------------------
// 🟢 Color Theme Persistence
// ---------------------------------------------------------------------------
static const _colorThemeKey = 'color_theme_type';
/// Save selected color theme
static Future<void> saveColorTheme(ColorThemeType type) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_colorThemeKey, type.name);
instance.colorTheme = type;
_notify();
}
/// Load saved color theme (called at startup)
static Future<void> _loadColorTheme() async {
final prefs = await SharedPreferences.getInstance();
final savedType = prefs.getString(_colorThemeKey);
if (savedType != null) {
instance.colorTheme = ColorThemeType.values.firstWhere(
(e) => e.name == savedType,
orElse: () => ColorThemeType.red,
);
}
}
/// Change color theme & persist
static Future<void> changeColorTheme(ColorThemeType type) async {
oldInstance = instance.clone();
instance.colorTheme = type;
await saveColorTheme(type);
} }
} }

View File

@ -0,0 +1,270 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/wave_background.dart';
import 'package:marco/helpers/theme/admin_theme.dart';
import 'package:marco/helpers/theme/theme_customizer.dart';
class ThemeOption {
final String label;
final Color primary;
final Color button;
final Color brand;
final ColorThemeType colorThemeType;
ThemeOption(
this.label, this.primary, this.button, this.brand, this.colorThemeType);
}
final List<ThemeOption> themeOptions = [
ThemeOption(
"Theme 1", Colors.red, Colors.red, Colors.red, ColorThemeType.red),
ThemeOption(
"Theme 2",
const Color(0xFF49BF3C),
const Color(0xFF49BF3C),
const Color(0xFF49BF3C),
ColorThemeType.green,
),
ThemeOption(
"Theme 3",
const Color(0xFF3F51B5),
const Color(0xFF3F51B5),
const Color(0xFF3F51B5),
ColorThemeType.blue,
),
ThemeOption(
"Theme 4",
const Color(0xFF663399),
const Color(0xFF663399),
const Color(0xFF663399),
ColorThemeType.purple,
),
];
class ThemeController extends GetxController {
RxInt selectedIndex = 0.obs;
RxBool showApplied = false.obs;
void init() {
final currentPrimary = AdminTheme.theme.contentTheme.primary;
int index = themeOptions
.indexWhere((opt) => opt.primary.value == currentPrimary.value);
selectedIndex.value = index == -1 ? 0 : index;
}
void applyTheme(int index) async {
selectedIndex.value = index;
showApplied.value = true;
ThemeCustomizer.instance.colorTheme = themeOptions[index].colorThemeType;
ThemeCustomizer.applyThemeChange();
await Future.delayed(const Duration(milliseconds: 600));
showApplied.value = false;
}
}
class ThemeEditorWidget extends StatefulWidget {
final VoidCallback onClose;
const ThemeEditorWidget({super.key, required this.onClose});
@override
_ThemeEditorWidgetState createState() => _ThemeEditorWidgetState();
}
class _ThemeEditorWidgetState extends State<ThemeEditorWidget> {
final ThemeController themeController = Get.put(ThemeController());
@override
void initState() {
super.initState();
themeController.init();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row with title and close button
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyLarge("Theme Customization", fontWeight: 600),
IconButton(
icon: const Icon(Icons.close),
onPressed: widget.onClose,
tooltip: "Back",
iconSize: 20,
),
],
),
const SizedBox(height: 12),
// Theme cards wrapped in reactive Obx widget
Center(
child: Obx(
() => Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.center,
children: List.generate(themeOptions.length, (i) {
return ThemeCard(
themeOption: themeOptions[i],
isSelected: themeController.selectedIndex.value == i,
onTap: () => themeController.applyTheme(i),
);
}),
),
),
),
const SizedBox(height: 12),
// Applied indicator reactive widget
Obx(
() => themeController.showApplied.value
? Padding(
padding: const EdgeInsets.only(top: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check_circle,
color:
themeOptions[themeController.selectedIndex.value]
.brand,
size: 20,
),
const SizedBox(width: 6),
Text(
"Theme Applied!",
style: TextStyle(
color: themeOptions[
themeController.selectedIndex.value]
.brand,
fontWeight: FontWeight.w700,
),
),
],
),
)
: const SizedBox(),
),
const SizedBox(height: 16),
const Text(
"Preview and select a theme. You can change this anytime.",
style: TextStyle(fontSize: 13, color: Colors.black54),
),
],
),
);
}
}
class ThemeCard extends StatelessWidget {
final ThemeOption themeOption;
final bool isSelected;
final VoidCallback onTap;
const ThemeCard({
Key? key,
required this.themeOption,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 80,
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
elevation: isSelected ? 4 : 1,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? themeOption.brand : Colors.transparent,
width: 2,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 80,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Stack(
fit: StackFit.expand,
children: [
CustomPaint(
painter: RedWavePainter(themeOption.brand, 0.15)),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"Hello, User!",
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w600,
color: themeOption.primary,
fontSize: 12,
),
),
const SizedBox(height: 4),
SizedBox(
height: 18,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: themeOption.button,
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
elevation: 1,
textStyle: const TextStyle(fontSize: 10),
),
onPressed: () {},
child: const Text("Welcome"),
),
),
],
),
),
],
),
),
),
const SizedBox(height: 6),
Text(
themeOption.label,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
color: Colors.grey[700],
),
),
],
),
),
),
),
);
}
}

View File

@ -95,7 +95,7 @@ class AttendanceButtonHelper {
} }
} }
static Color getButtonColor({ static Color getprimary({
required bool isYesterday, required bool isYesterday,
required bool isTodayApproved, required bool isTodayApproved,
required int activity, required int activity,

View File

@ -1,15 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class BaseBottomSheet extends StatelessWidget { class BaseBottomSheet extends StatefulWidget {
final String title; final String title;
final String? subtitle;
final Widget child; final Widget child;
final VoidCallback onCancel; final VoidCallback onCancel;
final VoidCallback onSubmit; final VoidCallback onSubmit;
final bool isSubmitting; final bool isSubmitting;
final String submitText; final String submitText;
final Color submitColor; final Color? submitColor;
final IconData submitIcon; final IconData submitIcon;
final bool showButtons; final bool showButtons;
final Widget? bottomContent; final Widget? bottomContent;
@ -20,18 +22,26 @@ class BaseBottomSheet extends StatelessWidget {
required this.child, required this.child,
required this.onCancel, required this.onCancel,
required this.onSubmit, required this.onSubmit,
this.subtitle,
this.isSubmitting = false, this.isSubmitting = false,
this.submitText = 'Submit', this.submitText = 'Submit',
this.submitColor = Colors.indigo, this.submitColor,
this.submitIcon = Icons.check_circle_outline, this.submitIcon = Icons.check_circle_outline,
this.showButtons = true, this.showButtons = true,
this.bottomContent, this.bottomContent,
}); });
@override
State<BaseBottomSheet> createState() => _BaseBottomSheetState();
}
class _BaseBottomSheetState extends State<BaseBottomSheet> with UIMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final effectiveSubmitColor =
widget.submitColor ?? contentTheme.primary;
return SingleChildScrollView( return SingleChildScrollView(
padding: mediaQuery.viewInsets, padding: mediaQuery.viewInsets,
@ -50,33 +60,50 @@ class BaseBottomSheet extends StatelessWidget {
], ],
), ),
child: SafeArea( child: SafeArea(
// 👈 prevents overlap with nav bar
top: false, top: false,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MySpacing.height(5), MySpacing.height(5),
Container( Center(
width: 40, child: Container(
height: 5, width: 40,
decoration: BoxDecoration( height: 5,
color: Colors.grey.shade300, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10), color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
), ),
), ),
MySpacing.height(12), MySpacing.height(12),
MyText.titleLarge(title, fontWeight: 700), Center(
child: MyText.titleLarge(
widget.title,
fontWeight: 700,
textAlign: TextAlign.center,
),
),
if (widget.subtitle != null &&
widget.subtitle!.isNotEmpty) ...[
MySpacing.height(4),
MyText.bodySmall(
widget.subtitle!,
fontWeight: 600,
color: Colors.grey[700],
),
],
MySpacing.height(12), MySpacing.height(12),
child, widget.child,
MySpacing.height(12), MySpacing.height(12),
if (showButtons) ...[ if (widget.showButtons) ...[
Row( Row(
children: [ children: [
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: onCancel, onPressed: widget.onCancel,
icon: const Icon(Icons.close, color: Colors.white), icon: const Icon(Icons.close, color: Colors.white),
label: MyText.bodyMedium( label: MyText.bodyMedium(
"Cancel", "Cancel",
@ -88,34 +115,40 @@ class BaseBottomSheet extends StatelessWidget {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
padding: const EdgeInsets.symmetric(vertical: 8), padding:
const EdgeInsets.symmetric(vertical: 8),
), ),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: isSubmitting ? null : onSubmit, onPressed:
icon: Icon(submitIcon, color: Colors.white), widget.isSubmitting ? null : widget.onSubmit,
icon:
Icon(widget.submitIcon, color: Colors.white),
label: MyText.bodyMedium( label: MyText.bodyMedium(
isSubmitting ? "Submitting..." : submitText, widget.isSubmitting
? "Submitting..."
: widget.submitText,
color: Colors.white, color: Colors.white,
fontWeight: 600, fontWeight: 600,
), ),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: submitColor, backgroundColor: effectiveSubmitColor,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
padding: const EdgeInsets.symmetric(vertical: 8), padding:
const EdgeInsets.symmetric(vertical: 8),
), ),
), ),
), ),
], ],
), ),
if (bottomContent != null) ...[ if (widget.bottomContent != null) ...[
MySpacing.height(12), MySpacing.height(12),
bottomContent!, widget.bottomContent!,
], ],
], ],
], ],

View File

@ -1,6 +1,9 @@
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class DateTimeUtils { class DateTimeUtils {
/// Default date format
static const String defaultFormat = 'dd MMM yyyy';
/// Converts a UTC datetime string to local time and formats it. /// Converts a UTC datetime string to local time and formats it.
static String convertUtcToLocal(String utcTimeString, static String convertUtcToLocal(String utcTimeString,
{String format = 'dd-MM-yyyy'}) { {String format = 'dd-MM-yyyy'}) {

View File

@ -1,3 +1,4 @@
import 'package:intl/intl.dart';
import 'package:marco/helpers/extensions/date_time_extension.dart'; import 'package:marco/helpers/extensions/date_time_extension.dart';
class Utils { class Utils {
@ -44,6 +45,10 @@ class Utils {
return "$hour:$minute${showSecond ? ":" : ""}$second$meridian"; return "$hour:$minute${showSecond ? ":" : ""}$second$meridian";
} }
static String formatDate(DateTime date) {
return DateFormat('d MMM yyyy').format(date);
}
static String getDateTimeStringFromDateTime(DateTime dateTime, static String getDateTimeStringFromDateTime(DateTime dateTime,
{bool showSecond = true, {bool showSecond = true,
bool showDate = true, bool showDate = true,
@ -76,4 +81,12 @@ class Utils {
return "${b.toStringAsFixed(2)} Bytes"; return "${b.toStringAsFixed(2)} Bytes";
} }
} }
static String formatCurrency(num amount,
{String currency = "INR", String locale = "en_US"}) {
// Use en_US for standard K, M, B formatting
final symbol = NumberFormat.simpleCurrency(name: currency).currencySymbol;
final formatter = NumberFormat.compact(locale: 'en_US');
return "$symbol${formatter.format(amount)}";
}
} }

View File

@ -1,135 +0,0 @@
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 {
final quill.QuillController controller;
final VoidCallback onCancel;
final Future<void> Function(quill.QuillController controller) onSave;
const CommentEditorCard({
super.key,
required this.controller,
required this.onCancel,
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,
configurations: const quill.QuillSimpleToolbarConfigurations(
showBoldButton: true,
showItalicButton: true,
showUnderLineButton: true,
showListBullets: false,
showListNumbers: false,
showAlignmentButtons: true,
showLink: true,
showFontSize: false,
showFontFamily: false,
showColorButton: false,
showBackgroundColorButton: false,
showUndo: false,
showRedo: false,
showCodeBlock: false,
showQuote: false,
showSuperscript: false,
showSubscript: false,
showInlineCode: false,
showDirection: false,
showListCheck: false,
showStrikeThrough: false,
showClearFormat: false,
showDividers: false,
showHeaderStyle: false,
multiRowsDisplay: false,
),
),
const SizedBox(height: 24),
Container(
height: 140,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
color: const Color(0xFFFDFDFD),
),
child: quill.QuillEditor.basic(
controller: widget.controller,
configurations: const quill.QuillEditorConfigurations(
autoFocus: true,
expands: false,
scrollable: true,
),
),
),
const SizedBox(height: 16),
// 👇 Buttons same as BaseBottomSheet
Row(
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),
),
),
),
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,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
],
),
],
);
}
}

View File

@ -60,7 +60,6 @@ class AttendanceDashboardChart extends StatelessWidget {
final filteredData = _getFilteredData(); final filteredData = _getFilteredData();
return Container( return Container(
decoration: _containerDecoration, decoration: _containerDecoration,
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
@ -254,7 +253,7 @@ class _AttendanceChart extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dateFormat = DateFormat('d MMMM'); final dateFormat = DateFormat('d MMM');
final uniqueDates = data final uniqueDates = data
.map((e) => DateTime.parse(e['date'] as String)) .map((e) => DateTime.parse(e['date'] as String))
.toSet() .toSet()
@ -273,10 +272,6 @@ class _AttendanceChart extends StatelessWidget {
if (allZero) { if (allZero) {
return Container( return Container(
height: 600, height: 600,
decoration: BoxDecoration(
color: Colors.blueGrey.shade50,
borderRadius: BorderRadius.circular(5),
),
child: const Center( child: const Center(
child: Text( child: Text(
'No attendance data for the selected range.', 'No attendance data for the selected range.',
@ -302,14 +297,22 @@ class _AttendanceChart extends StatelessWidget {
height: 600, height: 600,
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blueGrey.shade50,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
child: SfCartesianChart( child: SfCartesianChart(
tooltipBehavior: TooltipBehavior(enable: true, shared: true), tooltipBehavior: TooltipBehavior(enable: true, shared: true),
legend: Legend(isVisible: true, position: LegendPosition.bottom), legend: Legend(isVisible: true, position: LegendPosition.bottom),
primaryXAxis: CategoryAxis(labelRotation: 45), primaryXAxis: CategoryAxis(
primaryYAxis: NumericAxis(minimum: 0, interval: 1), labelRotation: 45,
majorGridLines:
const MajorGridLines(width: 0), // removes vertical grid lines
),
primaryYAxis: NumericAxis(
minimum: 0,
interval: 1,
majorGridLines:
const MajorGridLines(width: 0), // removes horizontal grid lines
),
series: rolesWithData.map((role) { series: rolesWithData.map((role) {
final seriesData = filteredDates final seriesData = filteredDates
.map((date) { .map((date) {
@ -317,7 +320,7 @@ class _AttendanceChart extends StatelessWidget {
return {'date': date, 'present': formattedMap[key] ?? 0}; return {'date': date, 'present': formattedMap[key] ?? 0};
}) })
.where((d) => (d['present'] ?? 0) > 0) .where((d) => (d['present'] ?? 0) > 0)
.toList(); // remove 0 bars .toList();
return StackedColumnSeries<Map<String, dynamic>, String>( return StackedColumnSeries<Map<String, dynamic>, String>(
dataSource: seriesData, dataSource: seriesData,
@ -358,7 +361,7 @@ class _AttendanceTable extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dateFormat = DateFormat('d MMMM'); final dateFormat = DateFormat('d MMM');
final uniqueDates = data final uniqueDates = data
.map((e) => DateTime.parse(e['date'] as String)) .map((e) => DateTime.parse(e['date'] as String))
.toSet() .toSet()
@ -377,10 +380,6 @@ class _AttendanceTable extends StatelessWidget {
if (allZero) { if (allZero) {
return Container( return Container(
height: 300, height: 300,
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(5),
),
child: const Center( child: const Center(
child: Text( child: Text(
'No attendance data for the selected range.', 'No attendance data for the selected range.',
@ -402,38 +401,49 @@ class _AttendanceTable extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
color: Colors.grey.shade50,
), ),
child: SingleChildScrollView( child: Scrollbar(
scrollDirection: Axis.horizontal, thumbVisibility: true,
child: DataTable( trackVisibility: true,
columnSpacing: screenWidth < 600 ? 20 : 36, child: SingleChildScrollView(
headingRowHeight: 44, scrollDirection: Axis.horizontal,
headingRowColor: child: ConstrainedBox(
MaterialStateProperty.all(Colors.blueAccent.withOpacity(0.08)), constraints:
headingTextStyle: const TextStyle( BoxConstraints(minWidth: MediaQuery.of(context).size.width),
fontWeight: FontWeight.bold, color: Colors.black87), child: SingleChildScrollView(
columns: [ scrollDirection: Axis.vertical,
const DataColumn(label: Text('Role')), child: DataTable(
...filteredDates.map((d) => DataColumn(label: Text(d))), columnSpacing: 20,
], headingRowHeight: 44,
rows: filteredRoles.map((role) { headingRowColor: MaterialStateProperty.all(
return DataRow( Colors.blueAccent.withOpacity(0.08)),
cells: [ headingTextStyle: const TextStyle(
DataCell(_RolePill(role: role, color: getRoleColor(role))), fontWeight: FontWeight.bold, color: Colors.black87),
...filteredDates.map((date) { columns: [
final key = '${role}_$date'; const DataColumn(label: Text('Role')),
return DataCell( ...filteredDates.map((d) => DataColumn(label: Text(d))),
Text( ],
NumberFormat.decimalPattern() rows: filteredRoles.map((role) {
.format(formattedMap[key] ?? 0), return DataRow(
style: const TextStyle(fontSize: 13), 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(),
], ),
); ),
}).toList(), ),
), ),
), ),
); );

View File

@ -0,0 +1,653 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/model/dashboard/expense_type_report_model.dart';
import 'package:marco/helpers/utils/utils.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
class ExpenseTypeReportChart extends StatelessWidget {
ExpenseTypeReportChart({Key? key}) : super(key: key);
final DashboardController _controller = Get.find<DashboardController>();
// Extended color palette for multiple projects
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 _getSeriesColor(int index) => _flatColors[index % _flatColors.length];
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isMobile = screenWidth < 600;
return Obx(() {
final isLoading = _controller.isExpenseTypeReportLoading.value;
final data = _controller.expenseTypeReportData.value;
return Container(
decoration: 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),
),
],
),
padding: EdgeInsets.symmetric(
vertical: isMobile ? 16 : 20,
horizontal: isMobile ? 12 : 20,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Chart Header
isLoading
? SkeletonLoaders.dateSkeletonLoader()
: _ChartHeader(controller: _controller),
const SizedBox(height: 12),
// Date Range Picker
isLoading
? Row(
children: [
Expanded(child: SkeletonLoaders.dateSkeletonLoader()),
const SizedBox(width: 8),
Expanded(child: SkeletonLoaders.dateSkeletonLoader()),
],
)
: _DateRangePicker(controller: _controller),
const SizedBox(height: 16),
// Chart Area
SizedBox(
height: isMobile ? 350 : 400,
child: isLoading
? SkeletonLoaders.chartSkeletonLoader()
: (data == null || data.report.isEmpty)
? const _NoDataMessage()
: _ExpenseDonutChart(
data: data,
getSeriesColor: _getSeriesColor,
isMobile: isMobile,
),
),
],
),
);
});
}
}
// -----------------------------------------------------------------------------
// Chart Header
// -----------------------------------------------------------------------------
class _ChartHeader extends StatelessWidget {
const _ChartHeader({Key? key, required this.controller}) : super(key: key);
final DashboardController controller;
@override
Widget build(BuildContext context) {
return Obx(() {
final data = controller.expenseTypeReportData.value;
// Calculate total from totalApprovedAmount only
final total = data?.report.fold<double>(
0,
(sum, e) => sum + e.totalApprovedAmount,
) ??
0;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium('Project Expense Analytics', fontWeight: 700),
const SizedBox(height: 2),
MyText.bodySmall('Approved expenses by project',
color: Colors.grey),
],
),
),
if (total > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(0.15),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.blueAccent, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
MyText.bodySmall(
'Total Approved',
color: Colors.blueAccent,
fontSize: 10,
),
MyText.bodyMedium(
Utils.formatCurrency(total),
color: Colors.blueAccent,
fontWeight: 700,
fontSize: 14,
),
],
),
),
],
);
});
}
}
// -----------------------------------------------------------------------------
// Date Range Picker
// -----------------------------------------------------------------------------
class _DateRangePicker extends StatelessWidget {
const _DateRangePicker({Key? key, required this.controller})
: super(key: key);
final DashboardController controller;
Future<void> _selectDate(
BuildContext context, bool isStartDate, DateTime currentDate) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: currentDate,
firstDate: DateTime(2020),
lastDate: DateTime.now(),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: Colors.blueAccent,
onPrimary: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
);
if (picked != null) {
if (isStartDate) {
controller.expenseReportStartDate.value = picked;
} else {
controller.expenseReportEndDate.value = picked;
}
}
}
@override
Widget build(BuildContext context) {
return Obx(() {
final startDate = controller.expenseReportStartDate.value;
final endDate = controller.expenseReportEndDate.value;
return Row(
children: [
_DateBox(
label: 'Start Date',
date: startDate,
onTap: () => _selectDate(context, true, startDate),
icon: Icons.calendar_today_outlined,
),
const SizedBox(width: 8),
_DateBox(
label: 'End Date',
date: endDate,
onTap: () => _selectDate(context, false, endDate),
icon: Icons.event_outlined,
),
],
);
});
}
}
class _DateBox extends StatelessWidget {
final String label;
final DateTime date;
final VoidCallback onTap;
final IconData icon;
const _DateBox({
Key? key,
required this.label,
required this.date,
required this.onTap,
required this.icon,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Expanded(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(5),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(0.08),
border: Border.all(color: Colors.blueAccent.withOpacity(0.3)),
borderRadius: BorderRadius.circular(5),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(0.15),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
icon,
size: 14,
color: Colors.blueAccent,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 10,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
Utils.formatDate(date),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.blueAccent,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
),
);
}
}
// -----------------------------------------------------------------------------
// No Data Message
// -----------------------------------------------------------------------------
class _NoDataMessage extends StatelessWidget {
const _NoDataMessage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.donut_large_outlined,
color: Colors.grey.shade400, size: 48),
const SizedBox(height: 10),
MyText.bodyMedium(
'No expense data available for this range.',
textAlign: TextAlign.center,
color: Colors.grey.shade500,
),
],
),
);
}
}
// -----------------------------------------------------------------------------
// Donut Chart
// -----------------------------------------------------------------------------
class _ExpenseDonutChart extends StatefulWidget {
const _ExpenseDonutChart({
Key? key,
required this.data,
required this.getSeriesColor,
required this.isMobile,
}) : super(key: key);
final ExpenseTypeReportData data;
final Color Function(int index) getSeriesColor;
final bool isMobile;
@override
State<_ExpenseDonutChart> createState() => _ExpenseDonutChartState();
}
class _ExpenseDonutChartState extends State<_ExpenseDonutChart> {
late TooltipBehavior _tooltipBehavior;
late SelectionBehavior _selectionBehavior;
@override
void initState() {
super.initState();
_tooltipBehavior = TooltipBehavior(
enable: true,
builder: (dynamic data, dynamic point, dynamic series, int pointIndex,
int seriesIndex) {
final total = widget.data.report
.fold<double>(0, (sum, e) => sum + e.totalApprovedAmount);
final value = data.value as double;
final percentage = total > 0 ? (value / total * 100) : 0;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Colors.blueAccent,
borderRadius: BorderRadius.circular(4),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data.label,
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.w600),
),
const SizedBox(height: 2),
Text(
Utils.formatCurrency(value),
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.w600),
),
Text(
'${percentage.toStringAsFixed(1)}%',
style: const TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w500,
fontSize: 10),
),
],
),
);
},
elevation: 4,
animationDuration: 300,
);
_selectionBehavior = SelectionBehavior(
enable: true,
selectedColor: Colors.white,
selectedBorderColor: Colors.blueAccent,
selectedBorderWidth: 3,
unselectedOpacity: 0.5,
);
}
@override
Widget build(BuildContext context) {
// Create donut data from project items using totalApprovedAmount
final List<_DonutData> donutData = widget.data.report
.asMap()
.entries
.map((entry) => _DonutData(
entry.value.projectName.isEmpty
? 'Project ${entry.key + 1}'
: entry.value.projectName,
entry.value.totalApprovedAmount,
widget.getSeriesColor(entry.key),
Icons.folder_outlined,
))
.toList();
// Filter out zero values for cleaner visualization
final filteredData = donutData.where((data) => data.value > 0).toList();
if (filteredData.isEmpty) {
return const Center(
child: Text(
'No approved expense data for the selected range.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
),
);
}
// Calculate total for center display
final total = filteredData.fold<double>(0, (sum, item) => sum + item.value);
return Column(
children: [
Expanded(
child: SfCircularChart(
margin: EdgeInsets.zero,
legend: Legend(
isVisible: true,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
iconHeight: 10,
iconWidth: 10,
itemPadding: widget.isMobile ? 12 : 20,
padding: widget.isMobile ? 20 : 28,
),
tooltipBehavior: _tooltipBehavior,
// Center annotation showing total approved amount
annotations: <CircularChartAnnotation>[
CircularChartAnnotation(
widget: Container(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle_outline,
color: Colors.green.shade600,
size: widget.isMobile ? 28 : 32,
),
const SizedBox(height: 6),
Text(
'Total Approved',
style: TextStyle(
fontSize: widget.isMobile ? 11 : 12,
color: Colors.grey.shade600,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
Utils.formatCurrency(total),
style: TextStyle(
fontSize: widget.isMobile ? 16 : 18,
color: Colors.green.shade700,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
'${filteredData.length} ${filteredData.length == 1 ? 'Project' : 'Projects'}',
style: TextStyle(
fontSize: widget.isMobile ? 9 : 10,
color: Colors.grey.shade500,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
series: <DoughnutSeries<_DonutData, String>>[
DoughnutSeries<_DonutData, String>(
dataSource: filteredData,
xValueMapper: (datum, _) => datum.label,
yValueMapper: (datum, _) => datum.value,
pointColorMapper: (datum, _) => datum.color,
dataLabelMapper: (datum, _) {
final amount = Utils.formatCurrency(datum.value);
return widget.isMobile
? '$amount'
: '${datum.label}\n$amount';
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.outside,
connectorLineSettings: ConnectorLineSettings(
type: ConnectorType.curve,
length: widget.isMobile ? '15%' : '18%',
width: 1.5,
color: Colors.grey.shade400,
),
textStyle: TextStyle(
fontSize: widget.isMobile ? 10 : 11,
fontWeight: FontWeight.w700,
color: Colors.black87,
),
labelIntersectAction: LabelIntersectAction.shift,
),
innerRadius: widget.isMobile ? '65%' : '70%',
radius: widget.isMobile ? '75%' : '80%',
explode: true,
explodeAll: false,
explodeIndex: 0,
explodeOffset: '5%',
explodeGesture: ActivationMode.singleTap,
startAngle: 90,
endAngle: 450,
strokeColor: Colors.white,
strokeWidth: 2.5,
enableTooltip: true,
animationDuration: 1000,
selectionBehavior: _selectionBehavior,
opacity: 0.95,
),
],
),
),
if (!widget.isMobile) ...[
const SizedBox(height: 12),
_ProjectSummary(donutData: filteredData),
],
],
);
}
}
// -----------------------------------------------------------------------------
// Project Summary (Desktop only)
// -----------------------------------------------------------------------------
class _ProjectSummary extends StatelessWidget {
const _ProjectSummary({Key? key, required this.donutData}) : super(key: key);
final List<_DonutData> donutData;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: donutData.map((data) {
return Container(
constraints: const BoxConstraints(minWidth: 120),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: data.color.withOpacity(0.15),
borderRadius: BorderRadius.circular(5),
border: Border.all(
color: data.color.withOpacity(0.4),
width: 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(data.icon, color: data.color, size: 18),
const SizedBox(height: 4),
Text(
data.label,
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade700,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
Utils.formatCurrency(data.value),
style: TextStyle(
fontSize: 12,
color: data.color,
fontWeight: FontWeight.w700,
),
),
],
),
);
}).toList(),
);
}
}
class _DonutData {
final String label;
final double value;
final Color color;
final IconData icon;
_DonutData(this.label, this.value, this.color, this.icon);
}

View File

@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/utils.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/controller/expense/expense_screen_controller.dart';
import 'package:marco/view/expense/expense_screen.dart';
import 'package:collection/collection.dart';
class ExpenseByStatusWidget extends StatelessWidget {
final DashboardController controller;
const ExpenseByStatusWidget({super.key, required this.controller});
Widget _buildStatusTile({
required IconData icon,
required Color color,
required String title,
required String amount,
required String count,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
CircleAvatar(
backgroundColor: color.withOpacity(0.15),
radius: 22,
child: Icon(icon, color: color, size: 24),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium(title, fontWeight: 600),
const SizedBox(height: 2),
MyText.titleMedium(amount,
color: Colors.blue, fontWeight: 700),
],
),
),
MyText.titleMedium(count, color: Colors.blue, fontWeight: 700),
const Icon(Icons.chevron_right, color: Colors.blue, size: 24),
],
),
),
);
}
// Navigate with status filter
Future<void> _navigateToExpenseWithFilter(
BuildContext context, String statusName) async {
final expenseController = Get.put(ExpenseController());
// 1 Ensure global projects and master data are loaded
if (expenseController.projectsMap.isEmpty) {
await expenseController.fetchGlobalProjects();
}
if (expenseController.expenseStatuses.isEmpty) {
await expenseController.fetchMasterData();
}
// 2 Auto-select current project from DashboardController
final dashboardController = Get.find<DashboardController>();
final currentProjectId =
dashboardController.projectController.selectedProjectId.value;
final projectName = expenseController.projectsMap.entries
.firstWhereOrNull((entry) => entry.value == currentProjectId)
?.key;
expenseController.selectedProject.value = projectName ?? '';
// 3 Select status filter
final matchedStatus = expenseController.expenseStatuses.firstWhereOrNull(
(e) => e.name.toLowerCase() == statusName.toLowerCase(),
);
expenseController.selectedStatus.value = matchedStatus?.id ?? '';
// 4 Fetch expenses immediately with applied filters
await expenseController.fetchExpenses();
// 5 Navigate to Expense screen
Get.to(() => const ExpenseMainScreen());
}
// Navigate without status filter
Future<void> _navigateToExpenseWithoutFilter() async {
final expenseController = Get.put(ExpenseController());
// Ensure global projects loaded
if (expenseController.projectsMap.isEmpty) {
await expenseController.fetchGlobalProjects();
}
// Auto-select current project
final dashboardController = Get.find<DashboardController>();
final currentProjectId =
dashboardController.projectController.selectedProjectId.value;
final projectName = expenseController.projectsMap.entries
.firstWhereOrNull((entry) => entry.value == currentProjectId)
?.key;
expenseController.selectedProject.value = projectName ?? '';
expenseController.selectedStatus.value = '';
// Fetch expenses with project filter (no status)
await expenseController.fetchExpenses();
// Navigate to Expense screen
Get.to(() => const ExpenseMainScreen());
}
@override
Widget build(BuildContext context) {
return Obx(() {
final data = controller.pendingExpensesData.value;
if (controller.isPendingExpensesLoading.value) {
return SkeletonLoaders.expenseByStatusSkeletonLoader();
}
if (data == null) {
return Center(
child: MyText.bodyMedium("No expense status data available"),
);
}
return Container(
padding: const EdgeInsets.all(16),
decoration: 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),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium("Expense - By Status", fontWeight: 700),
const SizedBox(height: 16),
// Status tiles
_buildStatusTile(
icon: Icons.currency_rupee,
color: Colors.blue,
title: "Pending Payment",
amount: Utils.formatCurrency(data.processPending.totalAmount),
count: data.processPending.count.toString(),
onTap: () {
_navigateToExpenseWithFilter(context, 'Payment Pending');
},
),
_buildStatusTile(
icon: Icons.check_circle_outline,
color: Colors.orange,
title: "Pending Approve",
amount: Utils.formatCurrency(data.approvePending.totalAmount),
count: data.approvePending.count.toString(),
onTap: () {
_navigateToExpenseWithFilter(context, 'Approval Pending');
},
),
_buildStatusTile(
icon: Icons.search,
color: Colors.grey.shade700,
title: "Pending Review",
amount: Utils.formatCurrency(data.reviewPending.totalAmount),
count: data.reviewPending.count.toString(),
onTap: () {
_navigateToExpenseWithFilter(context, 'Review Pending');
},
),
_buildStatusTile(
icon: Icons.insert_drive_file_outlined,
color: Colors.cyan,
title: "Draft",
amount: Utils.formatCurrency(data.draft.totalAmount),
count: data.draft.count.toString(),
onTap: () {
_navigateToExpenseWithFilter(context, 'Draft');
},
),
const SizedBox(height: 16),
Divider(color: Colors.grey.shade300),
const SizedBox(height: 12),
// Total row tap navigation (no filter)
InkWell(
onTap: _navigateToExpenseWithoutFilter,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium("Project Spendings:",
fontWeight: 600),
MyText.bodySmall("(All Processed Payments)",
color: Colors.grey.shade600),
],
),
Row(
children: [
MyText.titleLarge(
Utils.formatCurrency(data.totalAmount),
color: Colors.blue,
fontWeight: 700,
),
const SizedBox(width: 6),
const Icon(Icons.chevron_right,
color: Colors.blue, size: 22),
],
)
],
),
),
),
],
),
);
});
}
}

View File

@ -0,0 +1,520 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/utils.dart';
import 'package:intl/intl.dart';
// =========================
// CONSTANTS
// =========================
class _ChartConstants {
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 const Map<MonthlyExpenseDuration, String> durationLabels = {
MonthlyExpenseDuration.oneMonth: "1M",
MonthlyExpenseDuration.threeMonths: "3M",
MonthlyExpenseDuration.sixMonths: "6M",
MonthlyExpenseDuration.twelveMonths: "12M",
MonthlyExpenseDuration.all: "All",
};
static const double mobileBreakpoint = 600;
static const double mobileChartHeight = 350;
static const double desktopChartHeight = 400;
static const double mobilePadding = 12;
static const double desktopPadding = 20;
static const double mobileVerticalPadding = 16;
static const double desktopVerticalPadding = 20;
static const double noDataIconSize = 48;
static const double noDataContainerHeight = 220;
static const double labelRotation = 45;
static const int tooltipAnimationDuration = 300;
}
// =========================
// MAIN CHART WIDGET
// =========================
class MonthlyExpenseDashboardChart extends StatelessWidget {
MonthlyExpenseDashboardChart({Key? key}) : super(key: key);
final DashboardController _controller = Get.find<DashboardController>();
Color _getColorForIndex(int index) =>
_ChartConstants.flatColors[index % _ChartConstants.flatColors.length];
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),
),
],
);
bool _isMobileLayout(double screenWidth) =>
screenWidth < _ChartConstants.mobileBreakpoint;
double _calculateTotalExpense(List<dynamic> data) =>
data.fold<double>(0, (sum, item) => sum + item.total);
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isMobile = _isMobileLayout(screenWidth);
return Obx(() {
final isLoading = _controller.isMonthlyExpenseLoading.value;
final expenseData = _controller.monthlyExpenseList;
final selectedDuration = _controller.selectedMonthlyExpenseDuration.value;
final totalExpense = _calculateTotalExpense(expenseData);
return Container(
decoration: _containerDecoration,
padding: EdgeInsets.symmetric(
vertical: isMobile
? _ChartConstants.mobileVerticalPadding
: _ChartConstants.desktopVerticalPadding,
horizontal: isMobile
? _ChartConstants.mobilePadding
: _ChartConstants.desktopPadding,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ChartHeader(
controller: _controller, // pass controller explicitly
selectedDuration: selectedDuration,
onDurationChanged: _controller.updateMonthlyExpenseDuration,
totalExpense: totalExpense,
),
const SizedBox(height: 12),
SizedBox(
height: isMobile
? _ChartConstants.mobileChartHeight
: _ChartConstants.desktopChartHeight,
child: _buildChartContent(
isLoading: isLoading,
data: expenseData,
isMobile: isMobile,
totalExpense: totalExpense,
),
),
],
),
);
});
}
Widget _buildChartContent({
required bool isLoading,
required List<dynamic> data,
required bool isMobile,
required double totalExpense,
}) {
if (isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (data.isEmpty) {
return const _EmptyDataWidget();
}
return _MonthlyExpenseChart(
data: data,
getColor: _getColorForIndex,
isMobile: isMobile,
totalExpense: totalExpense,
);
}
}
// =========================
// HEADER WIDGET
// =========================
class _ChartHeader extends StatelessWidget {
const _ChartHeader({
Key? key,
required this.controller, // added
required this.selectedDuration,
required this.onDurationChanged,
required this.totalExpense,
}) : super(key: key);
final DashboardController controller; // added
final MonthlyExpenseDuration selectedDuration;
final ValueChanged<MonthlyExpenseDuration> onDurationChanged;
final double totalExpense;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitle(),
const SizedBox(height: 2),
_buildSubtitle(),
const SizedBox(height: 8),
// ==========================
// Row with popup menu on the right
// ==========================
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Obx(() {
final selectedType = controller.selectedExpenseType.value;
return PopupMenuButton<String>(
tooltip: 'Filter by Expense Type',
onSelected: (String value) {
if (value == 'all') {
controller.updateSelectedExpenseType(null);
} else {
final type = controller.expenseTypes
.firstWhere((t) => t.id == value);
controller.updateSelectedExpenseType(type);
}
},
itemBuilder: (context) {
final types = controller.expenseTypes;
return [
PopupMenuItem<String>(
value: 'all',
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('All Types'),
if (selectedType == null)
const Icon(Icons.check,
size: 16, color: Colors.blueAccent),
],
),
),
...types.map((type) => PopupMenuItem<String>(
value: type.id,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(type.name),
if (selectedType?.id == type.id)
const Icon(Icons.check,
size: 16, color: Colors.blueAccent),
],
),
)),
];
},
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
selectedType?.name ?? 'All Types',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14),
),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_drop_down, size: 20),
],
),
),
);
}),
],
),
const SizedBox(height: 8),
_buildDurationSelector(),
],
);
}
Widget _buildTitle() =>
MyText.bodyMedium('Monthly Expense Overview', fontWeight: 700);
Widget _buildSubtitle() =>
MyText.bodySmall('Month-wise total expense', color: Colors.grey);
Widget _buildDurationSelector() {
return Row(
children: _ChartConstants.durationLabels.entries
.map((entry) => _DurationChip(
label: entry.value,
duration: entry.key,
isSelected: selectedDuration == entry.key,
onSelected: onDurationChanged,
))
.toList(),
);
}
}
// =========================
// DURATION CHIP WIDGET
// =========================
class _DurationChip extends StatelessWidget {
const _DurationChip({
Key? key,
required this.label,
required this.duration,
required this.isSelected,
required this.onSelected,
}) : super(key: key);
final String label;
final MonthlyExpenseDuration duration;
final bool isSelected;
final ValueChanged<MonthlyExpenseDuration> onSelected;
@override
Widget build(BuildContext context) {
return 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: isSelected,
onSelected: (_) => onSelected(duration),
selectedColor: Colors.blueAccent.withOpacity(0.15),
backgroundColor: Colors.grey.shade200,
labelStyle: TextStyle(
color: isSelected ? Colors.blueAccent : Colors.black87,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
side: BorderSide(
color: isSelected ? Colors.blueAccent : Colors.grey.shade300,
),
),
),
);
}
}
// =========================
// EMPTY DATA WIDGET
// =========================
class _EmptyDataWidget extends StatelessWidget {
const _EmptyDataWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
height: _ChartConstants.noDataContainerHeight,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.info_outline,
color: Colors.grey.shade400,
size: _ChartConstants.noDataIconSize,
),
const SizedBox(height: 10),
MyText.bodyMedium(
'No monthly expense data available.',
textAlign: TextAlign.center,
color: Colors.grey.shade500,
),
],
),
),
);
}
}
// =========================
// CHART WIDGET
// =========================
class _MonthlyExpenseChart extends StatelessWidget {
const _MonthlyExpenseChart({
Key? key,
required this.data,
required this.getColor,
required this.isMobile,
required this.totalExpense,
}) : super(key: key);
final List<dynamic> data;
final Color Function(int index) getColor;
final bool isMobile;
final double totalExpense;
@override
Widget build(BuildContext context) {
return SfCartesianChart(
tooltipBehavior: _buildTooltipBehavior(),
primaryXAxis: _buildXAxis(),
primaryYAxis: _buildYAxis(),
series: <ColumnSeries>[_buildColumnSeries()],
);
}
TooltipBehavior _buildTooltipBehavior() {
return TooltipBehavior(
enable: true,
builder: _tooltipBuilder,
animationDuration: _ChartConstants.tooltipAnimationDuration,
);
}
Widget _tooltipBuilder(
dynamic data,
dynamic point,
dynamic series,
int pointIndex,
int seriesIndex,
) {
final value = data.total as double;
final percentage = totalExpense > 0 ? (value / totalExpense * 100) : 0;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Colors.blueAccent,
borderRadius: BorderRadius.circular(4),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${data.monthName} ${data.year}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
Utils.formatCurrency(value),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
Text(
'${percentage.toStringAsFixed(1)}%',
style: const TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w500,
fontSize: 10,
),
),
],
),
);
}
CategoryAxis _buildXAxis() {
return CategoryAxis(
labelRotation: _ChartConstants.labelRotation.toInt(),
majorGridLines:
const MajorGridLines(width: 0), // removes X-axis grid lines
);
}
NumericAxis _buildYAxis() {
return NumericAxis(
numberFormat: NumberFormat.simpleCurrency(
locale: 'en_IN',
name: '',
decimalDigits: 0,
),
axisLabelFormatter: (AxisLabelRenderDetails args) {
return ChartAxisLabel(Utils.formatCurrency(args.value), null);
},
majorGridLines:
const MajorGridLines(width: 0), // removes Y-axis grid lines
);
}
ColumnSeries<dynamic, String> _buildColumnSeries() {
return ColumnSeries<dynamic, String>(
dataSource: data,
xValueMapper: (d, _) => _ChartFormatter.formatMonthYear(d),
yValueMapper: (d, _) => d.total,
pointColorMapper: (_, index) => getColor(index),
name: 'Monthly Expense',
borderRadius: BorderRadius.circular(4),
dataLabelSettings: _buildDataLabelSettings(),
);
}
DataLabelSettings _buildDataLabelSettings() {
return DataLabelSettings(
isVisible: true,
builder: (data, _, __, ___, ____) => Text(
Utils.formatCurrency(data.total),
style: const TextStyle(fontSize: 11),
),
);
}
}
// =========================
// FORMATTER HELPER
// =========================
class _ChartFormatter {
static String formatMonthYear(dynamic data) {
try {
final month = data.month ?? 1;
final year = data.year ?? DateTime.now().year;
final date = DateTime(year, month, 1);
final monthName = DateFormat('MMM').format(date);
final shortYear = year % 100;
return '$shortYear $monthName';
} catch (e) {
return '${data.monthName} ${data.year}';
}
}
}

View File

@ -2,55 +2,51 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.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/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
class ProjectProgressChart extends StatelessWidget { class AttendanceDashboardChart extends StatelessWidget {
final List<ChartTaskData> data; AttendanceDashboardChart({Key? key}) : super(key: key);
final DashboardController controller = Get.find<DashboardController>();
ProjectProgressChart({super.key, required this.data}); final DashboardController _controller = Get.find<DashboardController>();
// ================= Flat Colors =================
static const List<Color> _flatColors = [ static const List<Color> _flatColors = [
Color(0xFFE57373), Color(0xFFE57373), // Red 300
Color(0xFF64B5F6), Color(0xFF64B5F6), // Blue 300
Color(0xFF81C784), Color(0xFF81C784), // Green 300
Color(0xFFFFB74D), Color(0xFFFFB74D), // Orange 300
Color(0xFFBA68C8), Color(0xFFBA68C8), // Purple 300
Color(0xFFFF8A65), Color(0xFFFF8A65), // Deep Orange 300
Color(0xFF4DB6AC), Color(0xFF4DB6AC), // Teal 300
Color(0xFFA1887F), Color(0xFFA1887F), // Brown 400
Color(0xFFDCE775), Color(0xFFDCE775), // Lime 300
Color(0xFF9575CD), Color(0xFF9575CD), // Deep Purple 300
Color(0xFF7986CB), Color(0xFF7986CB), // Indigo 300
Color(0xFFAED581), Color(0xFFAED581), // Light Green 300
Color(0xFFFF7043), Color(0xFFFF7043), // Deep Orange 400
Color(0xFF4FC3F7), Color(0xFF4FC3F7), // Light Blue 300
Color(0xFFFFD54F), Color(0xFFFFD54F), // Amber 300
Color(0xFF90A4AE), Color(0xFF90A4AE), // Blue Grey 300
Color(0xFFE573BB), Color(0xFFE573BB), // Pink 300
Color(0xFF81D4FA), Color(0xFF81D4FA), // Light Blue 200
Color(0xFFBCAAA4), Color(0xFFBCAAA4), // Brown 300
Color(0xFFA5D6A7), Color(0xFFA5D6A7), // Green 300
Color(0xFFCE93D8), Color(0xFFCE93D8), // Purple 200
Color(0xFFFF8A65), Color(0xFFFF8A65), // Deep Orange 300 (repeat to fill)
Color(0xFF80CBC4), Color(0xFF80CBC4), // Teal 200
Color(0xFFFFF176), Color(0xFFFFF176), // Yellow 300
Color(0xFF90CAF9), Color(0xFF90CAF9), // Blue 200
Color(0xFFE0E0E0), Color(0xFFE0E0E0), // Grey 300
Color(0xFFF48FB1), Color(0xFFF48FB1), // Pink 200
Color(0xFFA1887F), Color(0xFFA1887F), // Brown 400 (repeat)
Color(0xFFB0BEC5), Color(0xFFB0BEC5), // Blue Grey 200
Color(0xFF81C784), Color(0xFF81C784), // Green 300 (repeat)
Color(0xFFFFB74D), Color(0xFFFFB74D), // Orange 300 (repeat)
Color(0xFF64B5F6), Color(0xFF64B5F6), // Blue 300 (repeat)
]; ];
static final NumberFormat _commaFormatter = NumberFormat.decimalPattern();
Color _getTaskColor(String taskName) { Color _getRoleColor(String role) {
final index = taskName.hashCode % _flatColors.length; final index = role.hashCode.abs() % _flatColors.length;
return _flatColors[index]; return _flatColors[index];
} }
@ -59,42 +55,39 @@ class ProjectProgressChart extends StatelessWidget {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
return Obx(() { return Obx(() {
final isChartView = controller.projectIsChartView.value; final isChartView = _controller.attendanceIsChartView.value;
final selectedRange = controller.projectSelectedRange.value; final selectedRange = _controller.attendanceSelectedRange.value;
final filteredData = _getFilteredData();
return Container( return Container(
decoration: BoxDecoration( decoration: _containerDecoration,
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( padding: EdgeInsets.symmetric(
vertical: 16, vertical: 16,
horizontal: screenWidth < 600 ? 8 : 24, horizontal: screenWidth < 600 ? 8 : 20,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildHeader(selectedRange, isChartView, screenWidth), _Header(
const SizedBox(height: 14), selectedRange: selectedRange,
isChartView: isChartView,
screenWidth: screenWidth,
onToggleChanged: (isChart) =>
_controller.attendanceIsChartView.value = isChart,
onRangeChanged: _controller.updateAttendanceRange,
),
const SizedBox(height: 12),
Expanded( Expanded(
child: LayoutBuilder( child: filteredData.isEmpty
builder: (context, constraints) => AnimatedSwitcher( ? _NoDataMessage()
duration: const Duration(milliseconds: 300), : isChartView
child: data.isEmpty ? _AttendanceChart(
? _buildNoDataMessage() data: filteredData, getRoleColor: _getRoleColor)
: isChartView : _AttendanceTable(
? _buildChart(constraints.maxHeight) data: filteredData,
: _buildTable(constraints.maxHeight, screenWidth), getRoleColor: _getRoleColor,
), screenWidth: screenWidth),
),
), ),
], ],
), ),
@ -102,21 +95,62 @@ class ProjectProgressChart extends StatelessWidget {
}); });
} }
Widget _buildHeader( BoxDecoration get _containerDecoration => BoxDecoration(
String selectedRange, bool isChartView, double screenWidth) { 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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.bodyMedium('Project Progress', fontWeight: 700), MyText.bodyMedium('Attendance Overview', fontWeight: 700),
MyText.bodySmall('Planned vs Completed', const SizedBox(height: 2),
color: Colors.grey.shade700), MyText.bodySmall('Role-wise present count',
color: Colors.grey),
], ],
), ),
), ),
@ -129,12 +163,10 @@ class ProjectProgressChart extends StatelessWidget {
color: Colors.grey, color: Colors.grey,
constraints: BoxConstraints( constraints: BoxConstraints(
minHeight: 30, minHeight: 30,
minWidth: (screenWidth < 400 ? 28 : 36), minWidth: screenWidth < 400 ? 28 : 36,
), ),
isSelected: [isChartView, !isChartView], isSelected: [isChartView, !isChartView],
onPressed: (index) { onPressed: (index) => onToggleChanged(index == 0),
controller.projectIsChartView.value = index == 0;
},
children: const [ children: const [
Icon(Icons.bar_chart_rounded, size: 15), Icon(Icons.bar_chart_rounded, size: 15),
Icon(Icons.table_chart, size: 15), Icon(Icons.table_chart, size: 15),
@ -142,211 +174,62 @@ class ProjectProgressChart extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 6), const SizedBox(height: 8),
Row( Row(
children: [ children: ["7D", "15D", "30D"]
_buildRangeButton("7D", selectedRange), .map(
_buildRangeButton("15D", selectedRange), (label) => Padding(
_buildRangeButton("30D", selectedRange), padding: const EdgeInsets.only(right: 4),
_buildRangeButton("3M", selectedRange), child: ChoiceChip(
_buildRangeButton("6M", selectedRange), 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(),
), ),
], ],
); );
} }
}
Widget _buildRangeButton(String label, String selectedRange) { // No Data
return Padding( class _NoDataMessage extends StatelessWidget {
padding: const EdgeInsets.only(right: 4.0), @override
child: ChoiceChip( Widget build(BuildContext context) {
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(
color: Colors.blueGrey.shade50,
borderRadius: BorderRadius.circular(5),
),
child: SfCartesianChart(
tooltipBehavior: TooltipBehavior(enable: true),
legend: Legend(isVisible: true, position: LegendPosition.bottom),
// Use CategoryAxis so only nonZeroData dates show up
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.grey.shade50,
),
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
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.blueGrey.shade50,
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( return SizedBox(
height: 180, height: 180,
child: Center( child: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.info_outline, color: Colors.grey.shade400, size: 54), Icon(Icons.info_outline, color: Colors.grey.shade400, size: 48),
const SizedBox(height: 10), const SizedBox(height: 10),
MyText.bodyMedium( MyText.bodyMedium(
'No project progress data available for the selected range.', 'No attendance data available for this range.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
color: Colors.grey.shade500, color: Colors.grey.shade500,
), ),
@ -356,3 +239,237 @@ class ProjectProgressChart extends StatelessWidget {
); );
} }
} }
// 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,
majorGridLines:
const MajorGridLines(width: 0), // removes vertical grid lines
),
primaryYAxis: NumericAxis(
minimum: 0,
interval: 1,
majorGridLines:
const MajorGridLines(width: 0), // removes horizontal grid lines
),
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: Center(child: 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(
Center(
child: Text(
NumberFormat.decimalPattern()
.format(formattedMap[key] ?? 0),
textAlign: TextAlign.center,
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

@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/utils/utils.dart';
import 'package:marco/helpers/widgets/my_text.dart';
typedef OnDateRangeSelected = void Function(DateTime? start, DateTime? end);
class DateRangePickerWidget extends StatefulWidget {
final Rx<DateTime?> startDate;
final Rx<DateTime?> endDate;
final OnDateRangeSelected? onDateRangeSelected;
final String? startLabel;
final String? endLabel;
const DateRangePickerWidget({
Key? key,
required this.startDate,
required this.endDate,
this.onDateRangeSelected,
this.startLabel,
this.endLabel,
});
@override
State<DateRangePickerWidget> createState() => _DateRangePickerWidgetState();
}
class _DateRangePickerWidgetState extends State<DateRangePickerWidget>
with UIMixin {
Future<void> _selectDate(BuildContext context, bool isStartDate) async {
final current = isStartDate
? widget.startDate.value ?? DateTime.now()
: widget.endDate.value ?? DateTime.now();
final DateTime? picked = await showDatePicker(
context: context,
initialDate: current,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
builder: (context, child) => Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.light(
primary: contentTheme.primary,
onPrimary: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
),
);
if (picked != null) {
if (isStartDate) {
widget.startDate.value = picked;
} else {
widget.endDate.value = picked;
}
if (widget.onDateRangeSelected != null) {
widget.onDateRangeSelected!(
widget.startDate.value, widget.endDate.value);
}
}
}
Widget _dateBox({
required BuildContext context,
required String label,
required Rx<DateTime?> date,
required bool isStart,
}) {
return Expanded(
child: Obx(() {
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => _selectDate(context, isStart),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: contentTheme.primary.withOpacity(0.08),
border: Border.all(color: contentTheme.primary.withOpacity(0.3)),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: contentTheme.primary.withOpacity(0.15),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
isStart
? Icons.calendar_today_outlined
: Icons.event_outlined,
size: 14,
color: contentTheme.primary,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText(label, fontSize: 10, fontWeight: 500),
const SizedBox(height: 2),
MyText(
date.value != null
? Utils.formatDate(date.value!)
: 'Not selected',
fontWeight: 600,
color: contentTheme.primary,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
}),
);
}
@override
Widget build(BuildContext context) {
return Row(
children: [
_dateBox(
context: context,
label: widget.startLabel ?? 'Start Date',
date: widget.startDate,
isStart: true,
),
const SizedBox(width: 8),
_dateBox(
context: context,
label: widget.endLabel ?? 'End Date',
date: widget.endDate,
isStart: false,
),
],
);
}
}

View File

@ -1,4 +1,4 @@
// expense_form_widgets.dart // form_widgets.dart
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@ -6,7 +6,6 @@ import 'package:url_launcher/url_launcher_string.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/expense/add_expense_controller.dart';
/// 🔹 Common Colors & Styles /// 🔹 Common Colors & Styles
final _hintStyle = TextStyle(fontSize: 14, color: Colors.grey[600]); final _hintStyle = TextStyle(fontSize: 14, color: Colors.grey[600]);
@ -68,6 +67,7 @@ class CustomTextField extends StatelessWidget {
final int maxLines; final int maxLines;
final TextInputType keyboardType; final TextInputType keyboardType;
final String? Function(String?)? validator; final String? Function(String?)? validator;
final Widget? suffixIcon;
const CustomTextField({ const CustomTextField({
required this.controller, required this.controller,
@ -75,8 +75,9 @@ class CustomTextField extends StatelessWidget {
this.maxLines = 1, this.maxLines = 1,
this.keyboardType = TextInputType.text, this.keyboardType = TextInputType.text,
this.validator, this.validator,
this.suffixIcon,
Key? key, Key? key,
}) : super(key: key); }) ;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -92,6 +93,7 @@ class CustomTextField extends StatelessWidget {
fillColor: Colors.grey.shade100, fillColor: Colors.grey.shade100,
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 14), const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
suffixIcon: suffixIcon,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
@ -161,9 +163,10 @@ class TileContainer extends StatelessWidget {
} }
/// ========================== /// ==========================
/// Attachments Section /// Attachments Section (Reusable)
/// ========================== /// ==========================
class AttachmentsSection extends StatelessWidget { class AttachmentsSection<T extends GetxController> extends StatelessWidget {
final T controller; // 🔹 Now any controller can be passed
final RxList<File> attachments; final RxList<File> attachments;
final RxList<Map<String, dynamic>> existingAttachments; final RxList<Map<String, dynamic>> existingAttachments;
final ValueChanged<File> onRemoveNew; final ValueChanged<File> onRemoveNew;
@ -171,6 +174,7 @@ class AttachmentsSection extends StatelessWidget {
final VoidCallback onAdd; final VoidCallback onAdd;
const AttachmentsSection({ const AttachmentsSection({
required this.controller,
required this.attachments, required this.attachments,
required this.existingAttachments, required this.existingAttachments,
required this.onRemoveNew, required this.onRemoveNew,
@ -239,8 +243,20 @@ class AttachmentsSection extends StatelessWidget {
), ),
)), )),
_buildActionTile(Icons.attach_file, onAdd), _buildActionTile(Icons.attach_file, onAdd),
_buildActionTile(Icons.camera_alt, _buildActionTile(Icons.camera_alt, () {
() => Get.find<AddExpenseController>().pickFromCamera()), // 🔹 Dynamically call pickFromCamera if it exists
final fn = controller as dynamic;
if (fn.pickFromCamera != null) {
fn.pickFromCamera();
} else {
showAppSnackbar(
title: 'Error',
message:
'This controller does not support camera attachments.',
type: SnackbarType.error,
);
}
}),
], ],
), ),
], ],
@ -402,7 +418,6 @@ class _AttachmentTile extends StatelessWidget {
); );
} }
/// map extensions to icons/colors
static (IconData, Color) _fileIcon(String ext) { static (IconData, Color) _fileIcon(String ext) {
switch (ext) { switch (ext) {
case 'pdf': case 'pdf':

View File

@ -9,6 +9,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/model/expense/expense_list_model.dart'; import 'package:marco/model/expense/expense_list_model.dart';
import 'package:marco/view/expense/expense_detail_screen.dart'; import 'package:marco/view/expense/expense_detail_screen.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
final ProjectController projectController; final ProjectController projectController;
@ -32,7 +33,7 @@ class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
IconButton( IconButton(
icon: const Icon(Icons.arrow_back_ios_new, icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20), color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'), onPressed: () => Get.offNamed('/dashboard/finance'),
), ),
MySpacing.width(8), MySpacing.width(8),
Expanded( Expanded(
@ -72,7 +73,7 @@ class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
} }
} }
class SearchAndFilter extends StatelessWidget { class SearchAndFilter extends StatefulWidget {
final TextEditingController controller; final TextEditingController controller;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
final VoidCallback onFilterTap; final VoidCallback onFilterTap;
@ -86,6 +87,11 @@ class SearchAndFilter extends StatelessWidget {
super.key, super.key,
}); });
@override
State<SearchAndFilter> createState() => _SearchAndFilterState();
}
class _SearchAndFilterState extends State<SearchAndFilter> with UIMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
@ -96,8 +102,8 @@ class SearchAndFilter extends StatelessWidget {
child: SizedBox( child: SizedBox(
height: 35, height: 35,
child: TextField( child: TextField(
controller: controller, controller: widget.controller,
onChanged: onChanged, onChanged: widget.onChanged,
decoration: InputDecoration( decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 12),
prefixIcon: prefixIcon:
@ -109,6 +115,11 @@ class SearchAndFilter extends StatelessWidget {
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide:
BorderSide(color: contentTheme.primary, width: 1.5),
),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
@ -124,7 +135,7 @@ class SearchAndFilter extends StatelessWidget {
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
const Icon(Icons.tune, color: Colors.black), const Icon(Icons.tune, color: Colors.black),
if (expenseController.isFilterApplied) if (widget.expenseController.isFilterApplied)
Positioned( Positioned(
top: -1, top: -1,
right: -1, right: -1,
@ -140,7 +151,7 @@ class SearchAndFilter extends StatelessWidget {
), ),
], ],
), ),
onPressed: onFilterTap, onPressed: widget.onFilterTap,
); );
}), }),
], ],

View File

@ -33,6 +33,289 @@ class SkeletonLoaders {
); );
} }
// Inside SkeletonLoaders class
static Widget paymentRequestListSkeletonLoader() {
return ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
itemCount: 6,
separatorBuilder: (_, __) =>
Divider(color: Colors.grey.shade300, height: 20),
itemBuilder: (context, index) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Category name placeholder
Container(
height: 14,
width: 120,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
// Payee placeholder
Row(
children: [
Container(
height: 12,
width: 50,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
height: 12,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
),
],
),
const SizedBox(height: 6),
// Due date and status placeholders
Row(
children: [
// Due date label + value
Row(
children: [
Container(
height: 12,
width: 50,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 6),
Container(
height: 12,
width: 80,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
],
),
const Spacer(),
// Status chip placeholder
Container(
height: 20,
width: 60,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
),
],
),
],
),
);
},
);
}
// Add this inside SkeletonLoaders class
static Widget paymentRequestDetailSkeletonLoader() {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 30),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 520),
child: MyCard.bordered(
paddingAll: 16,
borderRadiusAll: 8,
shadow: MyShadow(elevation: 3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header (Created At + Status)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 140,
height: 16,
color: Colors.grey.shade300,
),
Container(
width: 80,
height: 20,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5),
),
),
],
),
MySpacing.height(24),
// Parties Section
...List.generate(
4,
(index) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Container(
height: 14,
width: double.infinity,
color: Colors.grey.shade300,
),
)),
MySpacing.height(24),
// Details Table
...List.generate(
6,
(index) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Container(
height: 14,
width: double.infinity,
color: Colors.grey.shade300,
),
)),
MySpacing.height(24),
// Documents Section
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(
3,
(index) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Container(
height: 40,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
),
)),
),
MySpacing.height(24),
// Logs / Timeline
Column(
children: List.generate(
3,
(index) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 14,
width: 120,
color: Colors.grey.shade300,
),
MySpacing.height(6),
Container(
height: 12,
width: double.infinity,
color: Colors.grey.shade300,
),
MySpacing.height(6),
Container(
height: 12,
width: 80,
color: Colors.grey.shade300,
),
MySpacing.height(16),
],
),
),
],
)),
),
],
),
),
),
),
);
}
// Chart Skeleton Loader (Donut Chart)
static Widget chartSkeletonLoader() {
return MyCard.bordered(
paddingAll: 16,
borderRadiusAll: 12,
shadow: MyShadow(
elevation: 1.5,
position: MyShadowPosition.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Chart Header Placeholder
Container(
height: 16,
width: 180,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 16),
// Donut Skeleton Placeholder
Expanded(
child: Center(
child: Container(
width: 180,
height: 180,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.grey.shade300.withOpacity(0.5),
),
),
),
),
const SizedBox(height: 16),
// Legend placeholders
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(5, (index) {
return Container(
width: 100,
height: 14,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
);
}),
),
],
),
);
}
// Date Skeleton Loader // Date Skeleton Loader
static Widget dateSkeletonLoader() { static Widget dateSkeletonLoader() {
return Container( return Container(
@ -45,68 +328,135 @@ class SkeletonLoaders {
); );
} }
// Chart Skeleton Loader // Expense By Status Skeleton Loader
static Widget chartSkeletonLoader() { static Widget expenseByStatusSkeletonLoader() {
return MyCard.bordered( return Container(
margin: MySpacing.only(bottom: 12), padding: const EdgeInsets.all(16),
paddingAll: 16, decoration: BoxDecoration(
borderRadiusAll: 16, color: Colors.white,
shadow: MyShadow( borderRadius: BorderRadius.circular(5),
elevation: 1.5, boxShadow: [
position: MyShadowPosition.bottom, BoxShadow(
color: Colors.grey.withOpacity(0.05),
blurRadius: 6,
spreadRadius: 1,
offset: const Offset(0, 2),
),
],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Chart Title Placeholder // Title
Container( Container(
height: 14, height: 16,
width: 120, width: 160,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
), ),
MySpacing.height(20), const SizedBox(height: 16),
// Chart Bars (variable height for realism) // 4 Status Rows
SizedBox( ...List.generate(4, (index) {
height: 180, return Padding(
child: Row( padding: const EdgeInsets.symmetric(vertical: 6),
crossAxisAlignment: CrossAxisAlignment.end, child: Row(
children: List.generate(6, (index) { children: [
return Expanded( // Icon placeholder
child: Padding( Container(
padding: const EdgeInsets.symmetric(horizontal: 4), height: 44,
child: Container( width: 44,
height: decoration: BoxDecoration(
(60 + (index * 20)).toDouble(), // fake chart shape color: Colors.grey.shade300,
decoration: BoxDecoration( shape: BoxShape.circle,
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
), ),
), ),
); const SizedBox(width: 12),
}),
),
),
MySpacing.height(16), // Title + Amount
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 12,
width: 100,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
Container(
height: 12,
width: 60,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
// X-Axis Labels // Count + arrow placeholder
Container(
height: 12,
width: 30,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 6),
Icon(Icons.chevron_right,
color: Colors.grey.shade300, size: 24),
],
),
);
}),
const SizedBox(height: 16),
Divider(color: Colors.grey.shade300),
const SizedBox(height: 12),
// Bottom Row (Project Spendings)
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(6, (index) { children: [
return Container( Column(
height: 10, crossAxisAlignment: CrossAxisAlignment.start,
width: 30, children: [
Container(
height: 12,
width: 120,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Container(
height: 10,
width: 140,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
],
),
Container(
height: 16,
width: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
); ),
}), ],
), ),
], ],
), ),

View File

@ -1,106 +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 const Center(child: CircularProgressIndicator());
} 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)
];
// Listen to selectedOrganization.value
return _popupSelector(
currentValue: controller.currentSelection,
items: orgNames,
);
});
}
}

View File

@ -1,114 +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),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
return Obx(() {
if (controller.isLoadingServices.value) {
return const Center(child: CircularProgressIndicator());
}
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

@ -0,0 +1,97 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/services/app_logger.dart';
class TimestampImageHelper {
/// Adds a timestamp to an image file and returns a new File
static Future<File> addTimestamp({
required File imageFile,
Color textColor = Colors.white,
double fontSize = 60,
Color backgroundColor = Colors.black54,
double padding = 40,
double bottomPadding = 60,
}) async {
try {
// Read the image file
final bytes = await imageFile.readAsBytes();
final originalImage = await decodeImageFromList(bytes);
// Create a canvas
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
// Draw original image
final paint = Paint();
canvas.drawImage(originalImage, Offset.zero, paint);
// Timestamp text
final now = DateTime.now();
final timestamp = DateFormat('dd MMM yyyy hh:mm:ss a').format(now);
final textStyle = ui.TextStyle(
color: textColor,
fontSize: fontSize,
fontWeight: FontWeight.bold,
shadows: [
const ui.Shadow(
color: Colors.black,
offset: Offset(3, 3),
blurRadius: 6,
),
],
);
final paragraphStyle = ui.ParagraphStyle(textAlign: TextAlign.left);
final paragraphBuilder = ui.ParagraphBuilder(paragraphStyle)
..pushStyle(textStyle)
..addText(timestamp);
final paragraph = paragraphBuilder.build();
paragraph.layout(const ui.ParagraphConstraints(width: double.infinity));
final textWidth = paragraph.maxIntrinsicWidth;
final yPosition = originalImage.height - paragraph.height - bottomPadding;
final xPosition = (originalImage.width - textWidth) / 2;
// Draw background
final backgroundPaint = Paint()
..color = backgroundColor
..style = PaintingStyle.fill;
final backgroundRect = Rect.fromLTWH(
xPosition - padding,
yPosition - 15,
textWidth + padding * 2,
paragraph.height + 30,
);
canvas.drawRRect(
RRect.fromRectAndRadius(backgroundRect, const Radius.circular(8)),
backgroundPaint,
);
// Draw timestamp text
canvas.drawParagraph(paragraph, Offset(xPosition, yPosition));
// Convert canvas to image
final picture = recorder.endRecording();
final img = await picture.toImage(originalImage.width, originalImage.height);
final byteData = await img.toByteData(format: ui.ImageByteFormat.png);
final buffer = byteData!.buffer.asUint8List();
// Save to temporary file
final tempDir = await Directory.systemTemp.createTemp();
final timestampedFile = File('${tempDir.path}/timestamped_${DateTime.now().millisecondsSinceEpoch}.png');
await timestampedFile.writeAsBytes(buffer);
return timestampedFile;
} catch (e, stacktrace) {
logSafe("Error adding timestamp to image", level: LogLevel.error, error: e, stackTrace: stacktrace);
return imageFile; // fallback
}
}
}

View File

@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
class RedWaveBackground extends StatelessWidget {
final Color brandRed;
final double heightFactor;
const RedWaveBackground({
super.key,
required this.brandRed,
this.heightFactor = 0.2,
});
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: RedWavePainter(brandRed, heightFactor),
size: Size.infinite,
);
}
}
class RedWavePainter extends CustomPainter {
final Color brandRed;
final double heightFactor;
RedWavePainter(this.brandRed, this.heightFactor);
@override
void paint(Canvas canvas, Size size) {
final paint1 = Paint()
..shader = LinearGradient(
colors: [brandRed, const Color.fromARGB(255, 97, 22, 22)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
final path1 = Path()
..moveTo(0, size.height * heightFactor)
..quadraticBezierTo(
size.width * 0.25, size.height * 0.05,
size.width * 0.5, size.height * 0.15,
)
..quadraticBezierTo(
size.width * 0.75, size.height * 0.25,
size.width, size.height * 0.1,
)
..lineTo(size.width, 0)
..lineTo(0, 0)
..close();
canvas.drawPath(path1, paint1);
final paint2 = Paint()..color = brandRed.withOpacity(0.15);
final path2 = Path()
..moveTo(0, size.height * (heightFactor + 0.05))
..quadraticBezierTo(
size.width * 0.4, size.height * 0.1,
size.width, size.height * 0.2,
)
..lineTo(size.width, 0)
..lineTo(0, 0)
..close();
canvas.drawPath(path2, paint2);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}

View File

@ -41,33 +41,26 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
}); });
} }
Future<DateTime?> _pickRegularizationTime(DateTime checkInTime) async { Future<DateTime?> _pickRegularizationTime(DateTime referenceTime) async {
final pickedTime = await showTimePicker( final pickedTime = await showTimePicker(
context: context, context: context,
initialTime: TimeOfDay.fromDateTime(DateTime.now()), initialTime:
TimeOfDay.fromDateTime(referenceTime), // start from actual log time
); );
if (pickedTime == null) return null; if (pickedTime == null) return null;
final selected = DateTime( final selected = DateTime(
checkInTime.year, referenceTime.year,
checkInTime.month, referenceTime.month,
checkInTime.day, referenceTime.day,
pickedTime.hour, pickedTime.hour,
pickedTime.minute, pickedTime.minute,
); );
final now = DateTime.now(); final now = DateTime.now();
if (selected.isBefore(checkInTime)) { // Allow times before check-in for regularization, but never future
showAppSnackbar(
title: "Invalid Time",
message: "Time must be after check-in.",
type: SnackbarType.warning,
);
return null;
}
if (selected.isAfter(now)) { if (selected.isAfter(now)) {
showAppSnackbar( showAppSnackbar(
title: "Invalid Time", title: "Invalid Time",
@ -108,8 +101,10 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
break; break;
case 1: case 1:
final isOldCheckIn = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2); final isOldCheckIn =
final isOldCheckOut = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2); AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2);
final isOldCheckOut =
AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2);
if (widget.employee.checkOut == null && isOldCheckIn) { if (widget.employee.checkOut == null && isOldCheckIn) {
action = 2; action = 2;
@ -167,7 +162,9 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
String? markTime; String? markTime;
if (actionText == ButtonActions.requestRegularize) { if (actionText == ButtonActions.requestRegularize) {
selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!); selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!);
markTime = selectedTime != null ? DateFormat("hh:mm a").format(selectedTime) : null; markTime = selectedTime != null
? DateFormat("hh:mm a").format(selectedTime)
: null;
} else if (selectedTime != null) { } else if (selectedTime != null) {
markTime = DateFormat("hh:mm a").format(selectedTime); markTime = DateFormat("hh:mm a").format(selectedTime);
} }
@ -205,13 +202,17 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
final controller = widget.attendanceController; final controller = widget.attendanceController;
final isUploading = controller.uploadingStates[uniqueLogKey]?.value ?? false; final isUploading =
controller.uploadingStates[uniqueLogKey]?.value ?? false;
final emp = widget.employee; final emp = widget.employee;
final isYesterday = AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut); final isYesterday =
final isTodayApproved = AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn); AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut);
final isTodayApproved =
AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn);
final isApprovedButNotToday = final isApprovedButNotToday =
AttendanceButtonHelper.isApprovedButNotToday(emp.activity, isTodayApproved); AttendanceButtonHelper.isApprovedButNotToday(
emp.activity, isTodayApproved);
final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled( final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled(
isUploading: isUploading, isUploading: isUploading,
@ -227,7 +228,7 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
isTodayApproved: isTodayApproved, isTodayApproved: isTodayApproved,
); );
final buttonColor = AttendanceButtonHelper.getButtonColor( final primary = AttendanceButtonHelper.getprimary(
isYesterday: isYesterday, isYesterday: isYesterday,
isTodayApproved: isTodayApproved, isTodayApproved: isTodayApproved,
activity: emp.activity, activity: emp.activity,
@ -237,7 +238,7 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
isUploading: isUploading, isUploading: isUploading,
isButtonDisabled: isButtonDisabled, isButtonDisabled: isButtonDisabled,
buttonText: buttonText, buttonText: buttonText,
buttonColor: buttonColor, primary: primary,
onPressed: isButtonDisabled ? null : _handleButtonPressed, onPressed: isButtonDisabled ? null : _handleButtonPressed,
); );
}); });
@ -248,7 +249,7 @@ class AttendanceActionButtonUI extends StatelessWidget {
final bool isUploading; final bool isUploading;
final bool isButtonDisabled; final bool isButtonDisabled;
final String buttonText; final String buttonText;
final Color buttonColor; final Color primary;
final VoidCallback? onPressed; final VoidCallback? onPressed;
const AttendanceActionButtonUI({ const AttendanceActionButtonUI({
@ -256,7 +257,7 @@ class AttendanceActionButtonUI extends StatelessWidget {
required this.isUploading, required this.isUploading,
required this.isButtonDisabled, required this.isButtonDisabled,
required this.buttonText, required this.buttonText,
required this.buttonColor, required this.primary,
required this.onPressed, required this.onPressed,
}); });
@ -267,17 +268,17 @@ class AttendanceActionButtonUI extends StatelessWidget {
child: ElevatedButton( child: ElevatedButton(
onPressed: onPressed, onPressed: onPressed,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: buttonColor, backgroundColor: primary,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
textStyle: const TextStyle(fontSize: 12), textStyle: const TextStyle(fontSize: 12),
), ),
child: isUploading child: isUploading
? const SizedBox( ? Container(
width: 16, width: 60,
height: 16, height: 14,
child: CircularProgressIndicator( decoration: BoxDecoration(
strokeWidth: 2, color: Colors.white.withOpacity(0.5),
valueColor: AlwaysStoppedAnimation<Color>(Colors.white), borderRadius: BorderRadius.circular(4),
), ),
) )
: Row( : Row(
@ -288,7 +289,8 @@ class AttendanceActionButtonUI extends StatelessWidget {
if (buttonText.toLowerCase() == 'rejected') if (buttonText.toLowerCase() == 'rejected')
const Icon(Icons.close, size: 16, color: Colors.red), const Icon(Icons.close, size: 16, color: Colors.red),
if (buttonText.toLowerCase() == 'requested') if (buttonText.toLowerCase() == 'requested')
const Icon(Icons.hourglass_top, size: 16, color: Colors.orange), const Icon(Icons.hourglass_top,
size: 16, color: Colors.orange),
if (['approved', 'rejected', 'requested'] if (['approved', 'rejected', 'requested']
.contains(buttonText.toLowerCase())) .contains(buttonText.toLowerCase()))
const SizedBox(width: 4), const SizedBox(width: 4),
@ -342,7 +344,8 @@ Future<String?> _showCommentBottomSheet(
} }
return Padding( return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom),
child: BaseBottomSheet( child: BaseBottomSheet(
title: sheetTitle, // 👈 now showing full sentence as title title: sheetTitle, // 👈 now showing full sentence as title
onCancel: () => Navigator.of(context).pop(), onCancel: () => Navigator.of(context).pop(),
@ -375,6 +378,5 @@ Future<String?> _showCommentBottomSheet(
); );
} }
String capitalizeFirstLetter(String text) => String capitalizeFirstLetter(String text) =>
text.isEmpty ? text : text[0].toUpperCase() + text.substring(1); text.isEmpty ? text : text[0].toUpperCase() + text.substring(1);

View File

@ -4,8 +4,8 @@ import 'package:marco/controller/attendance/attendance_screen_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/date_range_picker.dart';
class AttendanceFilterBottomSheet extends StatefulWidget { class AttendanceFilterBottomSheet extends StatefulWidget {
final AttendanceController controller; final AttendanceController controller;
@ -35,80 +35,11 @@ class _AttendanceFilterBottomSheetState
} }
String getLabelText() { String getLabelText() {
final startDate = widget.controller.startDateAttendance; final start = DateTimeUtils.formatDate(
final endDate = widget.controller.endDateAttendance; widget.controller.startDateAttendance.value, 'dd MMM yyyy');
final end = DateTimeUtils.formatDate(
if (startDate != null && endDate != null) { widget.controller.endDateAttendance.value, 'dd MMM yyyy');
final start = return "$start - $end";
DateTimeUtils.formatDate(startDate, 'dd MMM yyyy');
final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy');
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() { List<Widget> buildMainFilters() {
@ -127,6 +58,7 @@ class _AttendanceFilterBottomSheetState
}).toList(); }).toList();
final List<Widget> widgets = [ final List<Widget> widgets = [
// 🔹 View Section
Padding( Padding(
padding: const EdgeInsets.only(bottom: 4), padding: const EdgeInsets.only(bottom: 4),
child: Align( child: Align(
@ -148,37 +80,7 @@ class _AttendanceFilterBottomSheetState
); );
}), }),
]; ];
// 🔹 Date Range (only for Attendance Logs)
// 🔹 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 const Center(child: CircularProgressIndicator());
} else if (widget.controller.organizations.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: MyText.bodyMedium(
"No organizations found",
fontWeight: 500,
color: Colors.grey,
),
),
);
}
return _buildOrganizationSelector(context);
}),
]);
// 🔹 Date Range only for attendanceLogs
if (tempSelectedTab == 'attendanceLogs') { if (tempSelectedTab == 'attendanceLogs') {
widgets.addAll([ widgets.addAll([
const Divider(), const Divider(),
@ -189,37 +91,16 @@ class _AttendanceFilterBottomSheetState
child: MyText.titleSmall("Date Range", fontWeight: 600), child: MyText.titleSmall("Date Range", fontWeight: 600),
), ),
), ),
InkWell( // Reusable DateRangePickerWidget
borderRadius: BorderRadius.circular(10), DateRangePickerWidget(
onTap: () async { startDate: widget.controller.startDateAttendance,
await widget.controller.selectDateRangeForAttendance( endDate: widget.controller.endDateAttendance,
context, startLabel: "Start Date",
widget.controller, endLabel: "End Date",
); onDateRangeSelected: (start, end) {
// Optional: trigger UI updates if needed
setState(() {}); setState(() {});
}, },
child: Ink(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
const Icon(Icons.date_range, color: Colors.black87),
const SizedBox(width: 12),
Expanded(
child: MyText.bodyMedium(
getLabelText(),
fontWeight: 500,
color: Colors.black87,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.black87),
],
),
),
), ),
]); ]);
} }
@ -237,7 +118,6 @@ class _AttendanceFilterBottomSheetState
onCancel: () => Navigator.pop(context), onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context, { onSubmit: () => Navigator.pop(context, {
'selectedTab': tempSelectedTab, 'selectedTab': tempSelectedTab,
'selectedOrganization': widget.controller.selectedOrganization?.id,
}), }),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -1,368 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class AssignTaskBottomSheet extends StatefulWidget {
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 DailyTaskPlanningController controller = Get.find();
final ProjectController projectController = Get.find();
final TextEditingController targetController = TextEditingController();
final TextEditingController descriptionController = TextEditingController();
final ScrollController _employeeListScrollController = ScrollController();
String? selectedProjectId;
@override
void initState() {
super.initState();
selectedProjectId = projectController.selectedProjectId.value;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (selectedProjectId != null) {
controller.fetchEmployeesByProject(selectedProjectId!);
}
});
}
@override
void dispose() {
_employeeListScrollController.dispose();
targetController.dispose();
descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Obx(() => BaseBottomSheet(
title: "Assign Task",
child: _buildAssignTaskForm(),
onCancel: () => Get.back(),
onSubmit: _onAssignTaskPressed,
isSubmitting: controller.isAssigningTask.value,
));
}
Widget _buildAssignTaskForm() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_infoRow(Icons.location_on, "Work Location",
"${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"),
Divider(),
_infoRow(Icons.pending_actions, "Pending Task of Activity",
"${widget.pendingTask}"),
Divider(),
GestureDetector(
onTap: _onRoleMenuPressed,
child: Row(
children: [
MyText.titleMedium("Select Team :", fontWeight: 600),
const SizedBox(width: 4),
const Icon(Icons.tune, color: Color.fromARGB(255, 95, 132, 255)),
],
),
),
MySpacing.height(8),
Container(
constraints: const BoxConstraints(maxHeight: 150),
child: _buildEmployeeList(),
),
MySpacing.height(8),
_buildSelectedEmployees(),
_buildTextField(
icon: Icons.track_changes,
label: "Target for Today :",
controller: targetController,
hintText: "Enter target",
keyboardType: TextInputType.number,
validatorType: "target",
),
MySpacing.height(24),
_buildTextField(
icon: Icons.description,
label: "Description :",
controller: descriptionController,
hintText: "Enter task description",
maxLines: 3,
validatorType: "description",
),
],
);
}
void _onRoleMenuPressed() {
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final Size screenSize = overlay.size;
showMenu(
context: context,
position: RelativeRect.fromLTRB(
screenSize.width / 2 - 100,
screenSize.height / 2 - 20,
screenSize.width / 2 - 100,
screenSize.height / 2 - 20,
),
items: [
const PopupMenuItem(value: 'all', child: Text("All Roles")),
...controller.roles.map((role) {
return PopupMenuItem(
value: role['id'].toString(),
child: Text(role['name'] ?? 'Unknown Role'),
);
}),
],
).then((value) {
if (value != null) {
controller.onRoleSelected(value == 'all' ? null : value);
}
});
}
Widget _buildEmployeeList() {
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,
child: ListView.builder(
controller: _employeeListScrollController,
shrinkWrap: true,
itemCount: filteredEmployees.length,
itemBuilder: (context, index) {
final employee = filteredEmployees[index];
final rxBool = controller.uploadingStates[employee.id];
return Obx(() => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Checkbox(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
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: const TextStyle(fontSize: 14))),
],
),
));
},
),
);
});
}
Widget _buildSelectedEmployees() {
return Obx(() {
if (controller.selectedEmployees.isEmpty) return Container();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: controller.selectedEmployees.map((e) {
return Obx(() {
final isSelected =
controller.uploadingStates[e.id]?.value ?? false;
if (!isSelected) return Container();
return Chip(
label:
Text(e.name, style: const TextStyle(color: Colors.white)),
backgroundColor: const Color.fromARGB(255, 95, 132, 255),
deleteIcon: const Icon(Icons.close, color: Colors.white),
onDeleted: () {
controller.uploadingStates[e.id]?.value = false;
controller.updateSelectedEmployees();
},
);
});
}).toList(),
),
);
});
}
Widget _buildTextField({
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: const InputDecoration(
hintText: '',
border: 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

@ -1,678 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'dart:io';
import 'dart:math' as math;
// --- Assumed Imports (ensure these paths are correct in your project) ---
import 'package:marco/controller/task_planning/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:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:marco/model/dailyTaskPlanning/create_task_botom_sheet.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
// --- Form Field Keys (Unchanged) ---
class _FormFieldKeys {
static const String assignedDate = 'assigned_date';
static const String assignedBy = 'assigned_by';
static const String workArea = 'work_area';
static const String activity = 'activity';
static const String plannedWork = 'planned_work';
static const String completedWork = 'completed_work';
static const String teamMembers = 'team_members';
static const String assigned = 'assigned';
static const String taskId = 'task_id';
static const String comment = 'comment';
}
// --- Main Widget: CommentTaskBottomSheet ---
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 final ReportTaskController controller;
List<Map<String, dynamic>> _sortedComments = [];
@override
void initState() {
super.initState();
controller = Get.put(ReportTaskController(),
tag: widget.taskData['taskId'] ?? UniqueKey().toString());
_initializeControllerData();
final comments = List<Map<String, dynamic>>.from(
widget.taskData['taskComments'] as List? ?? []);
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
});
_sortedComments = comments;
}
void _initializeControllerData() {
final data = widget.taskData;
final fieldMappings = {
_FormFieldKeys.assignedDate: data['assignedOn'],
_FormFieldKeys.assignedBy: data['assignedBy'],
_FormFieldKeys.workArea: data['location'],
_FormFieldKeys.activity: data['activity'],
_FormFieldKeys.plannedWork: data['plannedWork'],
_FormFieldKeys.completedWork: data['completedWork'],
_FormFieldKeys.teamMembers: (data['teamMembers'] as List?)?.join(', '),
_FormFieldKeys.assigned: data['assigned'],
_FormFieldKeys.taskId: data['taskId'],
};
for (final entry in fieldMappings.entries) {
controller.basicValidator.getController(entry.key)?.text =
entry.value ?? '';
}
controller.basicValidator.getController(_FormFieldKeys.comment)?.clear();
controller.selectedImages.clear();
}
String _timeAgo(String dateString) {
// This logic remains unchanged
try {
final date = DateTime.parse(dateString + "Z").toLocal();
final difference = DateTime.now().difference(date);
if (difference.inDays > 8) return DateFormat('dd-MM-yyyy').format(date);
if (difference.inDays >= 1)
return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago';
if (difference.inHours >= 1)
return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago';
if (difference.inMinutes >= 1)
return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago';
return 'just now';
} catch (e) {
debugPrint('Error parsing date for timeAgo: $e');
return dateString;
}
}
@override
Widget build(BuildContext context) {
// --- REFACTORING POINT ---
// The entire widget now returns a BaseBottomSheet, passing the content as its child.
// The GetBuilder provides reactive state (like isLoading) to the BaseBottomSheet.
return GetBuilder<ReportTaskController>(
tag: widget.taskData['taskId'] ?? '',
builder: (controller) {
return BaseBottomSheet(
title: "Task Details & Comments",
onCancel: () => Navigator.of(context).pop(),
onSubmit: _submitComment,
isSubmitting: controller.isLoading.value,
bottomContent: _buildCommentsSection(),
child: Form(
// moved to last
key: controller.basicValidator.formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeaderActions(),
MySpacing.height(12),
_buildTaskDetails(),
_buildReportedImages(),
_buildCommentInput(),
_buildImagePicker(),
],
),
),
);
},
);
}
// --- REFACTORING POINT ---
// The original _buildHeader is now split. The title is handled by BaseBottomSheet.
// This new widget contains the remaining actions from the header.
Widget _buildHeaderActions() {
return Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: () => _showCreateTaskBottomSheet(),
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,
),
),
),
);
}
Widget _buildTaskDetails() {
return Column(
children: [
_buildDetailRow(
"Assigned By",
controller.basicValidator
.getController(_FormFieldKeys.assignedBy)
?.text,
icon: Icons.person_outline),
_buildDetailRow(
"Work Area",
controller.basicValidator
.getController(_FormFieldKeys.workArea)
?.text,
icon: Icons.place_outlined),
_buildDetailRow(
"Activity",
controller.basicValidator
.getController(_FormFieldKeys.activity)
?.text,
icon: Icons.assignment_outlined),
_buildDetailRow(
"Planned Work",
controller.basicValidator
.getController(_FormFieldKeys.plannedWork)
?.text,
icon: Icons.schedule_outlined),
_buildDetailRow(
"Completed Work",
controller.basicValidator
.getController(_FormFieldKeys.completedWork)
?.text,
icon: Icons.done_all_outlined),
_buildTeamMembers(),
],
);
}
Widget _buildReportedImages() {
final imageUrls =
List<String>.from(widget.taskData['reportedPreSignedUrls'] ?? []);
if (imageUrls.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: _buildSectionHeader("Reported Images", Icons.image_outlined),
),
// --- Refactoring Note ---
// Using the reusable _ImageHorizontalListView widget.
_ImageHorizontalListView(
imageSources: imageUrls,
onPreview: (index) => _showImageViewer(imageUrls, index),
),
MySpacing.height(16),
],
);
}
Widget _buildCommentInput() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader("Add Note", Icons.comment_outlined),
MySpacing.height(8),
TextFormField(
validator:
controller.basicValidator.getValidation(_FormFieldKeys.comment),
controller:
controller.basicValidator.getController(_FormFieldKeys.comment),
keyboardType: TextInputType.multiline,
maxLines: null, // Allows for multiline input
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),
],
);
}
Widget _buildImagePicker() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader("Attach Photos", Icons.camera_alt_outlined),
MySpacing.height(12),
Obx(() {
final images = controller.selectedImages;
return Column(
children: [
// --- Refactoring Note ---
// Using the reusable _ImageHorizontalListView for picked images.
_ImageHorizontalListView(
imageSources: images.toList(),
onPreview: (index) => _showImageViewer(images.toList(), index),
onRemove: (index) => controller.removeImageAt(index),
emptyStatePlaceholder: Container(
height: 70,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300, width: 1.5),
color: Colors.grey.shade100,
),
child: Center(
child: Icon(Icons.photo_library_outlined,
size: 36, color: Colors.grey.shade400),
),
),
),
MySpacing.height(16),
Row(
children: [
_buildPickerButton(
onTap: () => controller.pickImages(fromCamera: true),
icon: Icons.camera_alt,
label: 'Capture',
),
MySpacing.width(12),
_buildPickerButton(
onTap: () => controller.pickImages(fromCamera: false),
icon: Icons.upload_file,
label: 'Upload',
),
],
),
],
);
}),
],
);
}
Widget _buildCommentsSection() {
if (_sortedComments.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(24),
_buildSectionHeader("Comments", Icons.chat_bubble_outline),
MySpacing.height(12),
// --- Refactoring Note ---
// Using a ListView instead of a fixed-height SizedBox for better responsiveness.
// It's constrained by the parent SingleChildScrollView.
ListView.builder(
shrinkWrap:
true, // Important for ListView inside SingleChildScrollView
physics:
const NeverScrollableScrollPhysics(), // Parent handles scrolling
itemCount: _sortedComments.length,
itemBuilder: (context, index) {
final comment = _sortedComments[index];
// --- Refactoring Note ---
// Extracted the comment item into its own widget for clarity.
return _CommentCard(
comment: comment,
timeAgo: _timeAgo(comment['date'] ?? ''),
onPreviewImage: (imageUrls, idx) =>
_showImageViewer(imageUrls, idx),
);
},
),
],
);
}
// --- Helper and Builder methods ---
Widget _buildDetailRow(String label, String? value,
{required IconData icon}) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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 != null && value.isNotEmpty ? value : "-",
color: Colors.black87,
),
),
],
),
);
}
Widget _buildSectionHeader(String title, IconData icon) {
return Row(
children: [
Icon(icon, size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall(title, fontWeight: 600),
],
);
}
Widget _buildTeamMembers() {
final teamMembersText = controller.basicValidator
.getController(_FormFieldKeys.teamMembers)
?.text ??
'';
final members = teamMembersText
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
if (members.isEmpty) return const SizedBox.shrink();
const double avatarSize = 32.0;
const double avatarOverlap = 22.0;
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Row(
children: [
Icon(Icons.group_outlined, size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall("Team:", fontWeight: 600),
MySpacing.width(12),
GestureDetector(
onTap: () => TeamBottomSheet.show(
context: context,
teamMembers: members.map((name) => _Member(name)).toList()),
child: SizedBox(
height: avatarSize,
// Calculate width based on number of avatars shown
width: (math.min(members.length, 3) * avatarOverlap) +
(avatarSize - avatarOverlap),
child: Stack(
children: [
...List.generate(math.min(members.length, 3), (i) {
return Positioned(
left: i * avatarOverlap,
child: Tooltip(
message: members[i],
child: Avatar(
firstName: members[i],
lastName: '',
size: avatarSize),
),
);
}),
if (members.length > 3)
Positioned(
left: 3 * avatarOverlap,
child: CircleAvatar(
radius: avatarSize / 2,
backgroundColor: Colors.grey.shade300,
child: MyText.bodySmall('+${members.length - 3}',
fontWeight: 600),
),
),
],
),
),
),
],
),
);
}
Widget _buildPickerButton(
{required VoidCallback onTap,
required IconData icon,
required String label}) {
return Expanded(
child: MyButton.outlined(
onPressed: onTap,
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 18, color: Colors.blueAccent),
MySpacing.width(8),
MyText.bodySmall(label, color: Colors.blueAccent, fontWeight: 600),
],
),
),
);
}
// --- Action Handlers ---
void _showCreateTaskBottomSheet() {
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(),
);
}
void _showImageViewer(List<dynamic> sources, int initialIndex) {
showDialog(
context: context,
barrierColor: Colors.black87,
builder: (_) => ImageViewerDialog(
imageSources: sources,
initialIndex: initialIndex,
),
);
}
Future<void> _submitComment() async {
if (controller.basicValidator.validateForm()) {
await controller.commentTask(
projectId: controller.basicValidator
.getController(_FormFieldKeys.taskId)
?.text ??
'',
comment: controller.basicValidator
.getController(_FormFieldKeys.comment)
?.text ??
'',
images: controller.selectedImages,
);
// Callback to the parent widget to refresh data if needed
widget.onCommentSuccess?.call();
}
}
}
// --- Refactoring Note ---
// A reusable widget for displaying a horizontal list of images.
// It can handle both network URLs (String) and local files (File).
class _ImageHorizontalListView extends StatelessWidget {
final List<dynamic> imageSources; // Can be List<String> or List<File>
final Function(int) onPreview;
final Function(int)? onRemove;
final Widget? emptyStatePlaceholder;
const _ImageHorizontalListView({
required this.imageSources,
required this.onPreview,
this.onRemove,
this.emptyStatePlaceholder,
});
@override
Widget build(BuildContext context) {
if (imageSources.isEmpty) {
return emptyStatePlaceholder ?? const SizedBox.shrink();
}
return SizedBox(
height: 70,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: imageSources.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final source = imageSources[index];
return GestureDetector(
onTap: () => onPreview(index),
child: Stack(
clipBehavior: Clip.none,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: source is File
? Image.file(source,
width: 70, height: 70, fit: BoxFit.cover)
: Image.network(
source as String,
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]),
),
),
),
if (onRemove != null)
Positioned(
top: -6,
right: -6,
child: GestureDetector(
onTap: () => onRemove!(index),
child: Container(
padding: const EdgeInsets.all(2),
decoration: const BoxDecoration(
color: Colors.red, shape: BoxShape.circle),
child: const Icon(Icons.close,
size: 16, color: Colors.white),
),
),
),
],
),
);
},
),
);
}
}
// --- Refactoring Note ---
// A dedicated widget for a single comment card. This cleans up the main
// widget's build method and makes the comment layout easier to manage.
class _CommentCard extends StatelessWidget {
final Map<String, dynamic> comment;
final String timeAgo;
final Function(List<String> imageUrls, int index) onPreviewImage;
const _CommentCard({
required this.comment,
required this.timeAgo,
required this.onPreviewImage,
});
@override
Widget build(BuildContext context) {
final commentedBy = comment['commentedBy'] ?? 'Unknown';
final commentText = comment['text'] ?? '-';
final imageUrls = List<String>.from(comment['preSignedUrls'] ?? []);
return Container(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Avatar(
firstName: commentedBy.split(' ').first,
lastName: commentedBy.split(' ').length > 1
? commentedBy.split(' ').last
: '',
size: 32,
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium(commentedBy,
fontWeight: 700, color: Colors.black87),
MyText.bodySmall(timeAgo,
color: Colors.black54, fontSize: 12),
],
),
),
],
),
MySpacing.height(12),
MyText.bodyMedium(commentText, color: Colors.black87),
if (imageUrls.isNotEmpty) ...[
MySpacing.height(12),
_ImageHorizontalListView(
imageSources: imageUrls,
onPreview: (index) => onPreviewImage(imageUrls, index),
),
],
],
),
);
}
}

View File

@ -1,213 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/task_planning/add_task_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
void showCreateTaskBottomSheet({
required String workArea,
required String activity,
required String completedWork,
required String unit,
required Function(String) onCategoryChanged,
required String parentTaskId,
required int plannedTask,
required String activityId,
required String workAreaId,
required VoidCallback onSubmit,
}) {
final controller = Get.put(AddTaskController());
final TextEditingController plannedTaskController =
TextEditingController(text: plannedTask.toString());
final TextEditingController descriptionController = TextEditingController();
Get.bottomSheet(
StatefulBuilder(
builder: (context, setState) {
return BaseBottomSheet(
title: "Create Task",
onCancel: () => Get.back(),
onSubmit: () async {
final plannedValue =
int.tryParse(plannedTaskController.text.trim()) ?? 0;
final comment = descriptionController.text.trim();
final selectedCategoryId = controller.selectedCategoryId.value;
if (selectedCategoryId == null) {
showAppSnackbar(
title: "error",
message: "Please select a work category!",
type: SnackbarType.error,
);
return;
}
final success = await controller.createTask(
parentTaskId: parentTaskId,
plannedTask: plannedValue,
comment: comment,
workAreaId: workAreaId,
activityId: activityId,
categoryId: selectedCategoryId,
);
if (success) {
Get.back();
Future.delayed(const Duration(milliseconds: 300), () {
onSubmit();
showAppSnackbar(
title: "Success",
message: "Task created successfully!",
type: SnackbarType.success,
);
});
}
},
submitText: "Submit",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_infoCardSection([
_infoRowWithIcon(
Icons.workspaces, "Selected Work Area", workArea),
_infoRowWithIcon(Icons.list_alt, "Selected Activity", activity),
_infoRowWithIcon(Icons.check_circle_outline, "Completed Work",
completedWork),
]),
const SizedBox(height: 16),
_sectionTitle(Icons.edit_calendar, "Planned Work"),
const SizedBox(height: 6),
_customTextField(
controller: plannedTaskController,
hint: "Enter planned work",
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
_sectionTitle(Icons.description_outlined, "Comment"),
const SizedBox(height: 6),
_customTextField(
controller: descriptionController,
hint: "Enter task description",
maxLines: 3,
),
const SizedBox(height: 16),
_sectionTitle(Icons.category_outlined, "Selected Work Category"),
const SizedBox(height: 6),
Obx(() {
final categoryMap = controller.categoryIdNameMap;
final String selectedName =
controller.selectedCategoryId.value != null
? (categoryMap[controller.selectedCategoryId.value!] ??
'Select Category')
: 'Select Category';
return Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: PopupMenuButton<String>(
padding: EdgeInsets.zero,
onSelected: (val) {
controller.selectCategory(val);
onCategoryChanged(val);
},
itemBuilder: (context) => categoryMap.entries
.map((entry) => PopupMenuItem<String>(
value: entry.key,
child: Text(entry.value),
))
.toList(),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
selectedName,
style: const TextStyle(
fontSize: 14, color: Colors.black87),
),
const Icon(Icons.arrow_drop_down),
],
),
),
);
}),
],
),
);
},
),
isScrollControlled: true,
);
}
Widget _sectionTitle(IconData icon, String title) {
return Row(
children: [
Icon(icon, color: Colors.grey[700], size: 18),
const SizedBox(width: 8),
MyText.bodyMedium(title, fontWeight: 600),
],
);
}
Widget _customTextField({
required TextEditingController controller,
required String hint,
int maxLines = 1,
TextInputType keyboardType = TextInputType.text,
}) {
return TextField(
controller: controller,
maxLines: maxLines,
keyboardType: keyboardType,
decoration: InputDecoration(
hintText: hint,
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
);
}
Widget _infoCardSection(List<Widget> children) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(children: children),
);
}
Widget _infoRowWithIcon(IconData icon, String title, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: Colors.grey[700], size: 18),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium(title, fontWeight: 600),
const SizedBox(height: 2),
MyText.bodySmall(value, color: Colors.grey[800]),
],
),
),
],
),
);
}

View File

@ -1,83 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/task_planning/daily_task_controller.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class DailyProgressReportFilter extends StatelessWidget {
final DailyTaskController controller;
final PermissionController permissionController;
const DailyProgressReportFilter({
super.key,
required this.controller,
required this.permissionController,
});
String getLabelText() {
final startDate = controller.startDateTask;
final endDate = controller.endDateTask;
if (startDate != null && endDate != null) {
final start = DateFormat('dd MM yyyy').format(startDate);
final end = DateFormat('dd MM yyyy').format(endDate);
return "$start - $end";
}
return "Select Date Range";
}
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
title: "Filter Tasks",
onCancel: () => Navigator.pop(context),
onSubmit: () {
Navigator.pop(context, {
'startDate': controller.startDateTask,
'endDate': controller.endDateTask,
});
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall("Select Date Range", fontWeight: 600),
const SizedBox(height: 8),
InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => controller.selectDateRangeForTaskData(
context,
controller,
),
child: Ink(
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Icon(Icons.date_range, color: Colors.blue.shade600),
const SizedBox(width: 12),
Expanded(
child: Text(
getLabelText(),
style: const TextStyle(
fontSize: 16,
color: Colors.black87,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
),
],
),
);
}
}

View File

@ -1,222 +0,0 @@
class TaskModel {
final DateTime assignmentDate;
final DateTime? reportedDate;
final String id;
final WorkItem? workItem;
final String workItemId;
final double plannedTask;
final double completedTask;
final AssignedBy assignedBy;
final AssignedBy? approvedBy;
final List<TeamMember> teamMembers;
final List<Comment> comments;
final List<String> reportedPreSignedUrls;
TaskModel({
required this.assignmentDate,
this.reportedDate,
required this.id,
required this.workItem,
required this.workItemId,
required this.plannedTask,
required this.completedTask,
required this.assignedBy,
this.approvedBy,
required this.teamMembers,
required this.comments,
required this.reportedPreSignedUrls,
});
factory TaskModel.fromJson(Map<String, dynamic> json) {
return TaskModel(
assignmentDate: DateTime.parse(json['assignmentDate']),
reportedDate: json['reportedDate'] != null
? DateTime.tryParse(json['reportedDate'])
: null,
id: json['id'],
workItem:
json['workItem'] != null ? WorkItem.fromJson(json['workItem']) : null,
workItemId: json['workItemId'],
plannedTask: (json['plannedTask'] as num).toDouble(),
completedTask: (json['completedTask'] as num).toDouble(),
assignedBy: AssignedBy.fromJson(json['assignedBy']),
approvedBy: json['approvedBy'] != null
? AssignedBy.fromJson(json['approvedBy'])
: null,
teamMembers: (json['teamMembers'] as List)
.map((e) => TeamMember.fromJson(e))
.toList(),
comments:
(json['comments'] as List).map((e) => Comment.fromJson(e)).toList(),
reportedPreSignedUrls: (json['reportedPreSignedUrls'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
[],
);
}
}
class WorkItem {
final String? id;
final ActivityMaster? activityMaster;
final WorkArea? workArea;
final double? plannedWork;
final double? completedWork;
final List<String> preSignedUrls;
WorkItem({
this.id,
this.activityMaster,
this.workArea,
this.plannedWork,
this.completedWork,
this.preSignedUrls = const [],
});
factory WorkItem.fromJson(Map<String, dynamic> json) {
return WorkItem(
id: json['id']?.toString(),
activityMaster: json['activityMaster'] != null
? ActivityMaster.fromJson(json['activityMaster'])
: null,
workArea:
json['workArea'] != null ? WorkArea.fromJson(json['workArea']) : null,
plannedWork: (json['plannedWork'] as num?)?.toDouble(),
completedWork: (json['completedWork'] as num?)?.toDouble(),
preSignedUrls: (json['preSignedUrls'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
[],
);
}
}
class ActivityMaster {
final String? id; // Added
final String activityName;
ActivityMaster({
this.id,
required this.activityName,
});
factory ActivityMaster.fromJson(Map<String, dynamic> json) {
return ActivityMaster(
id: json['id']?.toString(),
activityName: json['activityName'] ?? '',
);
}
}
class WorkArea {
final String? id; // Added
final String areaName;
final Floor? floor;
WorkArea({
this.id,
required this.areaName,
this.floor,
});
factory WorkArea.fromJson(Map<String, dynamic> json) {
return WorkArea(
id: json['id']?.toString(),
areaName: json['areaName'] ?? '',
floor: json['floor'] != null ? Floor.fromJson(json['floor']) : null,
);
}
}
class Floor {
final String floorName;
final Building? building;
Floor({required this.floorName, this.building});
factory Floor.fromJson(Map<String, dynamic> json) {
return Floor(
floorName: json['floorName'] ?? '',
building:
json['building'] != null ? Building.fromJson(json['building']) : null,
);
}
}
class Building {
final String name;
Building({required this.name});
factory Building.fromJson(Map<String, dynamic> json) {
return Building(name: json['name'] ?? '');
}
}
class AssignedBy {
final String id;
final String firstName;
final String? lastName;
AssignedBy({
required this.id,
required this.firstName,
this.lastName,
});
factory AssignedBy.fromJson(Map<String, dynamic> json) {
return AssignedBy(
id: json['id']?.toString() ?? '',
firstName: json['firstName'] ?? '',
lastName: json['lastName'],
);
}
}
class TeamMember {
final String id;
final String firstName;
final String? lastName;
TeamMember({
required this.id,
required this.firstName,
this.lastName,
});
factory TeamMember.fromJson(Map<String, dynamic> json) {
return TeamMember(
id: json['id']?.toString() ?? '',
firstName: json['firstName']?.toString() ?? '',
lastName: json['lastName']?.toString(),
);
}
}
class Comment {
final String comment;
final TeamMember commentedBy;
final DateTime timestamp;
final List<String> preSignedUrls;
Comment({
required this.comment,
required this.commentedBy,
required this.timestamp,
required this.preSignedUrls,
});
factory Comment.fromJson(Map<String, dynamic> json) {
return Comment(
comment: json['comment']?.toString() ?? '',
commentedBy: json['employee'] != null
? TeamMember.fromJson(json['employee'])
: TeamMember(id: '', firstName: '', lastName: null),
timestamp: DateTime.parse(json['commentDate'] ?? ''),
preSignedUrls: (json['preSignedUrls'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
[],
);
}
}

View File

@ -1,144 +0,0 @@
import 'package:flutter/material.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class DailyTaskPlanningFilter extends StatelessWidget {
final DailyTaskPlanningController controller;
final PermissionController permissionController;
const DailyTaskPlanningFilter({
super.key,
required this.controller,
required this.permissionController,
});
@override
Widget build(BuildContext context) {
String? tempSelectedProjectId = '654563563645';
bool showProjectList = false;
final accessibleProjects = controller.projects
.where((project) =>
permissionController.isUserAssignedToProject(project.id.toString()))
.toList();
return StatefulBuilder(builder: (context, setState) {
List<Widget> filterWidgets;
if (showProjectList) {
filterWidgets = accessibleProjects.isEmpty
? [
Padding(
padding: EdgeInsets.all(12.0),
child: Center(
child: MyText.titleSmall(
'No Projects Assigned',
fontWeight: 600,
),
),
),
]
: accessibleProjects.map((project) {
final isSelected =
tempSelectedProjectId == project.id.toString();
return ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
title: MyText.titleSmall(project.name),
trailing: isSelected ? const Icon(Icons.check) : null,
onTap: () {
setState(() {
tempSelectedProjectId = project.id.toString();
showProjectList = false;
});
},
);
}).toList();
} else {
final selectedProject = accessibleProjects.isNotEmpty
? accessibleProjects.firstWhere(
(p) => p.id.toString() == tempSelectedProjectId,
orElse: () => accessibleProjects[0],
)
: null;
final selectedProjectName = selectedProject?.name ?? "Select Project";
filterWidgets = [
Padding(
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Align(
alignment: Alignment.centerLeft,
child: MyText.titleSmall(
'Select Project',
fontWeight: 600,
),
),
),
ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
title: MyText.titleSmall(selectedProjectName),
trailing: const Icon(Icons.arrow_drop_down),
onTap: () => setState(() => showProjectList = true),
),
];
}
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(4),
),
),
),
),
...filterWidgets,
const Divider(),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Color.fromARGB(255, 95, 132, 255),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: MyText.titleSmall(
'Apply Filter',
fontWeight: 600,
color: Colors.white,
),
onPressed: () {
Navigator.pop(context, {
'projectId': tempSelectedProjectId,
});
},
),
),
),
],
),
),
);
});
}
}

View File

@ -1,246 +0,0 @@
class TaskPlanningDetailsModel {
final List<Building> buildings;
final String id;
final String name;
final String projectAddress;
final String contactPerson;
final DateTime startDate;
final DateTime endDate;
final String projectStatusId;
TaskPlanningDetailsModel({
required this.buildings,
required this.id,
required this.name,
required this.projectAddress,
required this.contactPerson,
required this.startDate,
required this.endDate,
required this.projectStatusId,
});
factory TaskPlanningDetailsModel.fromJson(Map<String, dynamic> json) {
return TaskPlanningDetailsModel(
buildings: (json['buildings'] as List<dynamic>?)
?.map((b) => Building.fromJson(b))
.toList() ??
[],
id: json['id'],
name: json['name'],
projectAddress: json['projectAddress'],
contactPerson: json['contactPerson'],
startDate: DateTime.parse(json['startDate']),
endDate: DateTime.parse(json['endDate']),
projectStatusId: json['projectStatusId'],
);
}
}
class Building {
final String id;
final String name;
final String description;
final List<Floor> floors;
Building({
required this.id,
required this.name,
required this.description,
required this.floors,
});
factory Building.fromJson(Map<String, dynamic> json) {
return Building(
id: json['id'],
name: json['name'],
description: json['description'],
floors: (json['floors'] as List).map((f) => Floor.fromJson(f)).toList(),
);
}
}
class Floor {
final String id;
final String floorName;
final List<WorkArea> workAreas;
Floor({
required this.id,
required this.floorName,
required this.workAreas,
});
factory Floor.fromJson(Map<String, dynamic> json) {
return Floor(
id: json['id'],
floorName: json['floorName'],
workAreas:
(json['workAreas'] as List).map((w) => WorkArea.fromJson(w)).toList(),
);
}
}
class WorkArea {
final String id;
final String areaName;
final List<WorkItemWrapper> workItems;
WorkArea({
required this.id,
required this.areaName,
required this.workItems,
});
factory WorkArea.fromJson(Map<String, dynamic> json) {
return WorkArea(
id: json['id'],
areaName: json['areaName'],
workItems: (json['workItems'] as List)
.map((w) => WorkItemWrapper.fromJson(w))
.toList(),
);
}
}
class WorkItemWrapper {
final String workItemId;
final WorkItem workItem;
WorkItemWrapper({
required this.workItemId,
required this.workItem,
});
factory WorkItemWrapper.fromJson(Map<String, dynamic> json) {
return WorkItemWrapper(
workItemId: json['workItemId'],
workItem: WorkItem.fromJson(json['workItem']),
);
}
}
class WorkItem {
final String? id;
final String? activityId;
final String? workCategoryId;
final String? workAreaId;
final WorkAreaBasic? workArea;
final ActivityMaster? activityMaster;
final WorkCategoryMaster? workCategoryMaster;
final double? plannedWork;
final double? completedWork;
final String? description;
final double? todaysAssigned;
final DateTime? taskDate;
final String? tenantId;
final Tenant? tenant;
WorkItem({
this.id,
this.activityId,
this.workCategoryId,
this.workAreaId,
this.workArea,
this.activityMaster,
this.workCategoryMaster,
this.description,
this.plannedWork,
this.completedWork,
this.todaysAssigned,
this.taskDate,
this.tenantId,
this.tenant,
});
factory WorkItem.fromJson(Map<String, dynamic> json) {
return WorkItem(
id: json['id'] as String?,
activityId: json['activityId'] as String?,
workCategoryId: json['workCategoryId'] as String?,
workAreaId: json['workAreaId'] as String?,
workArea: json['workArea'] != null
? WorkAreaBasic.fromJson(json['workArea'] as Map<String, dynamic>)
: null,
activityMaster: json['activityMaster'] != null
? ActivityMaster.fromJson(
json['activityMaster'] as Map<String, dynamic>)
: null,
workCategoryMaster: json['workCategoryMaster'] != null
? WorkCategoryMaster.fromJson(
json['workCategoryMaster'] as Map<String, dynamic>)
: null,
plannedWork: json['plannedWork'] != null
? (json['plannedWork'] as num).toDouble()
: null,
completedWork: json['completedWork'] != null
? (json['completedWork'] as num).toDouble()
: null,
todaysAssigned: json['todaysAssigned'] != null
? (json['todaysAssigned'] as num).toDouble()
: null,
description: json['description'] as String?,
taskDate:
json['taskDate'] != null ? DateTime.tryParse(json['taskDate']) : null,
tenantId: json['tenantId'] as String?,
tenant: json['tenant'] != null
? Tenant.fromJson(json['tenant'] as Map<String, dynamic>)
: null,
);
}
}
class WorkAreaBasic {
final String? id;
final String? name;
WorkAreaBasic({this.id, this.name});
factory WorkAreaBasic.fromJson(Map<String, dynamic> json) {
return WorkAreaBasic(
id: json['id'] as String?,
name: json['name'] as String?,
);
}
}
class ActivityMaster {
final String? id;
final String? name;
ActivityMaster({this.id, this.name});
factory ActivityMaster.fromJson(Map<String, dynamic> json) {
return ActivityMaster(
id: json['id'] as String?,
name: json['activityName'] as String?,
);
}
}
class WorkCategoryMaster {
final String? id;
final String? name;
WorkCategoryMaster({this.id, this.name});
factory WorkCategoryMaster.fromJson(Map<String, dynamic> json) {
return WorkCategoryMaster(
id: json['id'] as String?,
name: json['name'] as String?,
);
}
}
class Tenant {
final String? id;
final String? name;
Tenant({this.id, this.name});
factory Tenant.fromJson(Map<String, dynamic> json) {
return Tenant(
id: json['id'] as String?,
name: json['name'] as String?,
);
}
}

View File

@ -1,31 +0,0 @@
class WorkCategoryModel {
final String id;
final String name;
final String description;
final bool isSystem;
WorkCategoryModel({
required this.id,
required this.name,
required this.description,
required this.isSystem,
});
factory WorkCategoryModel.fromJson(Map<String, dynamic> json) {
return WorkCategoryModel(
id: json['id'] ?? '',
name: json['name'] ?? '',
description: json['description'] ?? '',
isSystem: json['isSystem'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'isSystem': isSystem,
};
}
}

View File

@ -1,500 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/task_planning/report_task_action_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.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:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:marco/model/dailyTaskPlanning/create_task_botom_sheet.dart';
import 'package:marco/model/dailyTaskPlanning/report_action_widgets.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class ReportActionBottomSheet extends StatefulWidget {
final Map<String, dynamic> taskData;
final VoidCallback? onCommentSuccess;
final String taskDataId;
final String workAreaId;
final String activityId;
final VoidCallback onReportSuccess;
const ReportActionBottomSheet({
super.key,
required this.taskData,
this.onCommentSuccess,
required this.taskDataId,
required this.workAreaId,
required this.activityId,
required this.onReportSuccess,
});
@override
State<ReportActionBottomSheet> createState() =>
_ReportActionBottomSheetState();
}
class _Member {
final String firstName;
_Member(this.firstName);
}
class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
with UIMixin {
late ReportTaskActionController controller;
@override
void initState() {
super.initState();
controller = Get.put(
ReportTaskActionController(),
tag: widget.taskData['taskId'] ?? '',
);
controller.fetchWorkStatuses();
final data = widget.taskData;
controller.basicValidator.getController('approved_task')?.text =
data['approvedTask']?.toString() ?? '';
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 =
widget.taskDataId;
controller.basicValidator.getController('comment')?.clear();
controller.selectedImages.clear();
}
@override
Widget build(BuildContext context) {
return GetBuilder<ReportTaskActionController>(
tag: widget.taskData['taskId'] ?? '',
builder: (controller) {
return BaseBottomSheet(
title: "Take Report Action",
isSubmitting: controller.isLoading.value,
onCancel: () => Navigator.of(context).pop(),
onSubmit: () async {}, // not used since buttons moved
showButtons: false, // disable internal buttons
child: _buildForm(context, controller),
);
},
);
}
Widget _buildForm(
BuildContext context, ReportTaskActionController controller) {
return Form(
key: controller.basicValidator.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 📋 Task Details
buildRow("Assigned By",
controller.basicValidator.getController('assigned_by')?.text,
icon: Icons.person_outline),
buildRow("Work Area",
controller.basicValidator.getController('work_area')?.text,
icon: Icons.place_outlined),
buildRow("Activity",
controller.basicValidator.getController('activity')?.text,
icon: Icons.assignment_outlined),
buildRow("Planned Work",
controller.basicValidator.getController('planned_work')?.text,
icon: Icons.schedule_outlined),
buildRow("Completed Work",
controller.basicValidator.getController('completed_work')?.text,
icon: Icons.done_all_outlined),
buildTeamMembers(),
MySpacing.height(8),
// Approved Task Field
Row(
children: [
Icon(Icons.check_circle_outline,
size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall("Approved Task:", fontWeight: 600),
],
),
MySpacing.height(10),
TextFormField(
controller:
controller.basicValidator.getController('approved_task'),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) return 'Required';
if (int.tryParse(value) == null) return 'Must be a number';
return null;
},
decoration: InputDecoration(
hintText: "eg: 5",
hintStyle: MyTextStyle.bodySmall(xMuted: true),
border: outlineInputBorder,
contentPadding: MySpacing.all(16),
floatingLabelBehavior: FloatingLabelBehavior.never,
),
),
MySpacing.height(10),
if ((widget.taskData['reportedPreSignedUrls'] as List<dynamic>?)
?.isNotEmpty ==
true)
buildReportedImagesSection(
imageUrls: List<String>.from(
widget.taskData['reportedPreSignedUrls'] ?? []),
context: context,
),
MySpacing.height(10),
MyText.titleSmall("Report Actions", fontWeight: 600),
MySpacing.height(10),
Obx(() {
if (controller.isLoadingWorkStatus.value)
return const CircularProgressIndicator();
return PopupMenuButton<String>(
onSelected: (String value) {
controller.selectedWorkStatusName.value = value;
controller.showAddTaskCheckbox.value = true;
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
itemBuilder: (BuildContext context) {
return controller.workStatus.map((status) {
return PopupMenuItem<String>(
value: status.name,
child: Row(
children: [
Radio<String>(
value: status.name,
groupValue: controller.selectedWorkStatusName.value,
onChanged: (_) => Navigator.pop(context, status.name),
),
const SizedBox(width: 8),
MyText.bodySmall(status.name),
],
),
);
}).toList();
},
child: Container(
padding: MySpacing.xy(16, 12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodySmall(
controller.selectedWorkStatusName.value.isEmpty
? "Select Work Status"
: controller.selectedWorkStatusName.value,
color: Colors.black87,
),
const Icon(Icons.arrow_drop_down, size: 20),
],
),
),
);
}),
MySpacing.height(10),
Obx(() {
if (!controller.showAddTaskCheckbox.value)
return const SizedBox.shrink();
return Theme(
data: Theme.of(context).copyWith(
checkboxTheme: CheckboxThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
side: const BorderSide(
color: Colors.black, width: 2),
fillColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) {
return Colors.blueAccent;
}
return Colors.white;
}),
checkColor:
MaterialStateProperty.all(Colors.white),
),
),
child: CheckboxListTile(
title: MyText.titleSmall("Add new task", fontWeight: 600),
value: controller.isAddTaskChecked.value,
onChanged: (val) =>
controller.isAddTaskChecked.value = val ?? false,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
);
}),
MySpacing.height(24),
// Comment Field
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,
contentPadding: MySpacing.all(16),
floatingLabelBehavior: FloatingLabelBehavior.never,
),
),
MySpacing.height(16),
// 📸 Image Attachments
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(12),
// Submit/Cancel Buttons moved here
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => Navigator.of(context).pop(),
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: controller.isLoading.value
? null
: () async {
if (controller.basicValidator.validateForm()) {
final selectedStatusName =
controller.selectedWorkStatusName.value;
final selectedStatus = controller.workStatus
.firstWhereOrNull(
(s) => s.name == selectedStatusName);
final reportActionId =
selectedStatus?.id.toString() ?? '';
final approvedTaskCount = controller.basicValidator
.getController('approved_task')
?.text
.trim() ??
'';
final shouldShowAddTaskSheet =
controller.isAddTaskChecked.value;
final success = await controller.approveTask(
projectId: controller.basicValidator
.getController('task_id')
?.text ??
'',
comment: controller.basicValidator
.getController('comment')
?.text ??
'',
images: controller.selectedImages,
reportActionId: reportActionId,
approvedTaskCount: approvedTaskCount,
);
if (success) {
Navigator.of(context).pop();
if (shouldShowAddTaskSheet) {
await Future.delayed(
const Duration(milliseconds: 100));
showCreateTaskBottomSheet(
workArea: widget.taskData['location'] ?? '',
activity: widget.taskData['activity'] ?? '',
completedWork:
widget.taskData['completedWork'] ?? '',
unit: widget.taskData['unit'] ?? '',
parentTaskId: widget.taskDataId,
plannedTask: int.tryParse(
widget.taskData['plannedWork'] ??
'0') ??
0,
activityId: widget.activityId,
workAreaId: widget.workAreaId,
onSubmit: () => Navigator.of(context).pop(),
onCategoryChanged: (category) {},
);
}
widget.onReportSuccess.call();
}
}
},
icon: const Icon(Icons.check_circle_outline,
color: Colors.white),
label: MyText.bodyMedium(
controller.isLoading.value ? "Submitting..." : "Submit",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
],
),
MySpacing.height(12),
// 💬 Previous Comments List (only below submit)
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),
buildCommentList(
List<Map<String, dynamic>>.from(
widget.taskData['taskComments'] as List),
context,
timeAgo,
),
],
],
),
);
}
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),
),
),
),
],
),
),
),
],
),
);
}
}

View File

@ -1,392 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:get/get.dart';
/// Show labeled row with optional icon
Widget buildRow(String label, String? value, {IconData? icon}) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (icon != null)
Padding(
padding: const EdgeInsets.only(right: 8.0, top: 2),
child: Icon(icon, size: 18, color: Colors.grey[700]),
),
MyText.titleSmall("$label:", fontWeight: 600),
MySpacing.width(12),
Expanded(
child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"),
),
],
),
);
}
/// Show uploaded network images
Widget buildReportedImagesSection({
required List<String> imageUrls,
required BuildContext context,
String title = "Reported Images",
}) {
if (imageUrls.isEmpty) return const SizedBox();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(8),
Row(
children: [
Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall(title, fontWeight: 600),
],
),
MySpacing.height(8),
SizedBox(
height: 70,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: imageUrls.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final url = imageUrls[index];
return GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (_) => ImageViewerDialog(
imageSources: imageUrls,
initialIndex: index,
),
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
url,
width: 70,
height: 70,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
width: 70,
height: 70,
color: Colors.grey.shade200,
child: Icon(Icons.broken_image, color: Colors.grey[600]),
),
),
),
);
},
),
),
MySpacing.height(16),
],
);
}
/// Local image picker preview (with file images)
Widget buildImagePickerSection({
required List<File> images,
required VoidCallback onCameraTap,
required VoidCallback onUploadTap,
required void Function(int index) onRemoveImage,
required void Function(int initialIndex) onPreviewImage,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (images.isEmpty)
Container(
height: 70,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300, width: 2),
color: Colors.grey.shade100,
),
child: Center(
child: Icon(Icons.photo_camera_outlined,
size: 48, color: Colors.grey.shade400),
),
)
else
SizedBox(
height: 70,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: images.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final file = images[index];
return Stack(
children: [
GestureDetector(
onTap: () => onPreviewImage(index),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
file,
height: 70,
width: 70,
fit: BoxFit.cover,
),
),
),
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: () => onRemoveImage(index),
child: Container(
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(Icons.close,
size: 20, color: Colors.white),
),
),
),
],
);
},
),
),
MySpacing.height(16),
Row(
children: [
Expanded(
child: MyButton.outlined(
onPressed: onCameraTap,
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.camera_alt,
size: 16, color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Capture', color: Colors.blueAccent),
],
),
),
),
MySpacing.width(12),
Expanded(
child: MyButton.outlined(
onPressed: onUploadTap,
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.upload_file,
size: 16, color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Upload', color: Colors.blueAccent),
],
),
),
),
],
),
],
);
}
/// Comment list widget
Widget buildCommentList(
List<Map<String, dynamic>> comments, BuildContext context, String Function(String) timeAgo) {
comments.sort((a, b) {
final aDate = DateTime.tryParse(a['date'] ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0);
final bDate = DateTime.tryParse(b['date'] ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0);
return bDate.compareTo(aDate); // newest first
});
return SizedBox(
height: 300,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: comments.length,
itemBuilder: (context, index) {
final comment = comments[index];
final commentText = comment['text'] ?? '-';
final commentedBy = comment['commentedBy'] ?? 'Unknown';
final relativeTime = timeAgo(comment['date'] ?? '');
final imageUrls = List<String>.from(comment['preSignedUrls'] ?? []);
return Container(
margin: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Avatar(
firstName: commentedBy.split(' ').first,
lastName: commentedBy.split(' ').length > 1
? commentedBy.split(' ').last
: '',
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(commentedBy,
fontWeight: 700, color: Colors.black87),
MyText.bodySmall(
relativeTime,
fontSize: 12,
color: Colors.black54,
),
],
),
),
],
),
const SizedBox(height: 12),
MyText.bodyMedium(commentText,
fontWeight: 500, color: Colors.black87),
const SizedBox(height: 12),
if (imageUrls.isNotEmpty) ...[
Row(
children: [
Icon(Icons.attach_file_outlined,
size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.bodyMedium('Attachments',
fontWeight: 600, color: Colors.black87),
],
),
const SizedBox(height: 8),
SizedBox(
height: 60,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: imageUrls.length,
itemBuilder: (context, imageIndex) {
final imageUrl = imageUrls[imageIndex];
return GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (_) => ImageViewerDialog(
imageSources: imageUrls,
initialIndex: imageIndex,
),
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
imageUrl,
width: 60,
height: 60,
fit: BoxFit.cover,
),
),
);
},
separatorBuilder: (_, __) => const SizedBox(width: 12),
),
),
]
],
),
);
},
),
);
}
/// Cancel + Submit buttons
Widget buildCommentActionButtons({
required VoidCallback onCancel,
required Future<void> Function() onSubmit,
required RxBool isLoading,
}) {
return Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.close, color: Colors.red, size: 18),
label:
MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Obx(() {
return ElevatedButton.icon(
onPressed: isLoading.value ? null : () => onSubmit(),
icon: isLoading.value
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Icon(Icons.send, color: Colors.white, size: 18),
label: isLoading.value
? const SizedBox()
: MyText.bodyMedium("Submit",
color: Colors.white, fontWeight: 600),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
);
}),
),
],
);
}
/// Converts a UTC timestamp to a relative time string
String timeAgo(String dateString) {
try {
DateTime date = DateTime.parse(dateString + "Z").toLocal();
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays > 8) {
return "${date.day.toString().padLeft(2, '0')}-${date.month.toString().padLeft(2, '0')}-${date.year}";
} else if (difference.inDays >= 1) {
return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago';
} else if (difference.inHours >= 1) {
return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago';
} else if (difference.inMinutes >= 1) {
return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago';
} else {
return 'just now';
}
} catch (e) {
return '';
}
}

View File

@ -1,310 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/task_planning/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/utils/base_bottom_sheet.dart';
class ReportTaskBottomSheet extends StatefulWidget {
final Map<String, dynamic> taskData;
final VoidCallback? onReportSuccess;
const ReportTaskBottomSheet({
super.key,
required this.taskData,
this.onReportSuccess,
});
@override
State<ReportTaskBottomSheet> createState() => _ReportTaskBottomSheetState();
}
class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
with UIMixin {
late final ReportTaskController controller;
@override
void initState() {
super.initState();
controller = Get.put(
ReportTaskController(),
tag: widget.taskData['taskId'] ?? UniqueKey().toString(),
);
_preFillFormFields();
}
void _preFillFormFields() {
final data = widget.taskData;
final v = controller.basicValidator;
v.getController('assigned_date')?.text = data['assignedOn'] ?? '';
v.getController('assigned_by')?.text = data['assignedBy'] ?? '';
v.getController('work_area')?.text = data['location'] ?? '';
v.getController('activity')?.text = data['activity'] ?? '';
v.getController('team_size')?.text = data['teamSize']?.toString() ?? '';
v.getController('assigned')?.text = data['assigned'] ?? '';
v.getController('task_id')?.text = data['taskId'] ?? '';
v.getController('completed_work')?.clear();
v.getController('comment')?.clear();
}
@override
Widget build(BuildContext context) {
return Obx(() {
return BaseBottomSheet(
title: "Report Task",
isSubmitting: controller.reportStatus.value == ApiStatus.loading,
onCancel: () => Navigator.of(context).pop(),
onSubmit: _handleSubmit,
child: Form(
key: controller.basicValidator.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRow("Assigned Date", controller.basicValidator.getController('assigned_date')?.text),
_buildRow("Assigned By", controller.basicValidator.getController('assigned_by')?.text),
_buildRow("Work Area", controller.basicValidator.getController('work_area')?.text),
_buildRow("Activity", controller.basicValidator.getController('activity')?.text),
_buildRow("Team Size", controller.basicValidator.getController('team_size')?.text),
_buildRow(
"Assigned",
"${controller.basicValidator.getController('assigned')?.text ?? '-'} "
"of ${widget.taskData['pendingWork'] ?? '-'} Pending",
),
_buildCompletedWorkField(),
_buildCommentField(),
Obx(() => _buildImageSection()),
],
),
),
);
});
}
Future<void> _handleSubmit() async {
final v = controller.basicValidator;
if (v.validateForm()) {
final success = await controller.reportTask(
projectId: v.getController('task_id')?.text ?? '',
comment: v.getController('comment')?.text ?? '',
completedTask: int.tryParse(v.getController('completed_work')?.text ?? '') ?? 0,
checklist: [],
reportedDate: DateTime.now(),
images: controller.selectedImages,
);
if (success) {
widget.onReportSuccess?.call();
}
}
}
Widget _buildRow(String label, String? value) {
final icons = {
"Assigned Date": Icons.calendar_today_outlined,
"Assigned By": Icons.person_outline,
"Work Area": Icons.place_outlined,
"Activity": Icons.run_circle_outlined,
"Team Size": Icons.group_outlined,
"Assigned": Icons.assignment_turned_in_outlined,
};
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icons[label] ?? Icons.info_outline, size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall("$label:", fontWeight: 600),
MySpacing.width(12),
Expanded(
child: MyText.bodyMedium(value?.trim().isNotEmpty == true ? value!.trim() : "-"),
),
],
),
);
}
Widget _buildCompletedWorkField() {
final pending = widget.taskData['pendingWork'] ?? 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.work_outline, size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall("Completed Work:", fontWeight: 600),
],
),
MySpacing.height(8),
TextFormField(
controller: controller.basicValidator.getController('completed_work'),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.trim().isEmpty) return 'Please enter completed work';
final completed = int.tryParse(value.trim());
if (completed == null) return 'Enter a valid number';
if (completed > pending) return 'Completed work cannot exceed pending work $pending';
return null;
},
decoration: InputDecoration(
hintText: "eg: 10",
hintStyle: MyTextStyle.bodySmall(xMuted: true),
border: outlineInputBorder,
enabledBorder: outlineInputBorder,
focusedBorder: focusedInputBorder,
contentPadding: MySpacing.all(16),
isCollapsed: true,
floatingLabelBehavior: FloatingLabelBehavior.never,
),
),
MySpacing.height(24),
],
);
}
Widget _buildCommentField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall("Comment:", fontWeight: 600),
],
),
MySpacing.height(8),
TextFormField(
controller: controller.basicValidator.getController('comment'),
validator: controller.basicValidator.getValidation('comment'),
keyboardType: TextInputType.text,
decoration: InputDecoration(
hintText: "eg: Work done successfully",
hintStyle: MyTextStyle.bodySmall(xMuted: true),
border: outlineInputBorder,
enabledBorder: outlineInputBorder,
focusedBorder: focusedInputBorder,
contentPadding: MySpacing.all(16),
isCollapsed: true,
floatingLabelBehavior: FloatingLabelBehavior.never,
),
),
MySpacing.height(24),
],
);
}
Widget _buildImageSection() {
final images = controller.selectedImages;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.camera_alt_outlined, size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall("Attach Photos:", fontWeight: 600),
],
),
MySpacing.height(12),
if (images.isEmpty)
Container(
height: 70,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300, width: 2),
color: Colors.grey.shade100,
),
child: Center(
child: Icon(Icons.photo_camera_outlined, size: 48, color: Colors.grey.shade400),
),
)
else
SizedBox(
height: 70,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: images.length,
separatorBuilder: (_, __) => MySpacing.width(12),
itemBuilder: (context, index) {
final file = images[index];
return Stack(
children: [
GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (_) => Dialog(
child: InteractiveViewer(child: Image.file(file)),
),
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(file, height: 70, width: 70, fit: BoxFit.cover),
),
),
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: () => controller.removeImageAt(index),
child: Container(
decoration: BoxDecoration(color: Colors.black54, shape: BoxShape.circle),
child: const Icon(Icons.close, size: 20, color: Colors.white),
),
),
),
],
);
},
),
),
MySpacing.height(16),
Row(
children: [
Expanded(
child: MyButton.outlined(
onPressed: () => controller.pickImages(fromCamera: true),
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.camera_alt, size: 16, color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Capture', color: Colors.blueAccent),
],
),
),
),
MySpacing.width(12),
Expanded(
child: MyButton.outlined(
onPressed: () => controller.pickImages(fromCamera: false),
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.upload_file, size: 16, color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Upload', color: Colors.blueAccent),
],
),
),
),
],
),
],
);
}
}

View File

@ -1,210 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:marco/model/dailyTaskPlanning/comment_task_bottom_sheet.dart';
import 'package:marco/model/dailyTaskPlanning/report_task_bottom_sheet.dart';
import 'package:marco/model/dailyTaskPlanning/report_action_bottom_sheet.dart';
class TaskActionButtons {
static Widget reportButton({
required BuildContext context,
required dynamic task,
required int completed,
required VoidCallback refreshCallback,
}) {
return OutlinedButton.icon(
icon: const Icon(Icons.report, size: 18, color: Colors.blueAccent),
label: const Text('Report', style: TextStyle(color: Colors.blueAccent)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.blueAccent),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
textStyle: const TextStyle(fontSize: 14),
),
onPressed: () {
final activityName =
task.workItem?.activityMaster?.activityName ?? 'N/A';
final assigned = '${(task.plannedTask - completed)}';
final assignedBy =
"${task.assignedBy.firstName} ${task.assignedBy.lastName ?? ''}";
final assignedOn = DateFormat('dd-MM-yyyy').format(task.assignmentDate);
final taskId = task.id;
final location = [
task.workItem?.workArea?.floor?.building?.name,
task.workItem?.workArea?.floor?.floorName,
task.workItem?.workArea?.areaName,
].where((e) => e != null && e.isNotEmpty).join(' > ');
final teamMembers = task.teamMembers.map((e) => e.firstName).toList();
final pendingWork = (task.workItem?.plannedWork ?? 0) -
(task.workItem?.completedWork ?? 0);
final taskData = {
'activity': activityName,
'assigned': assigned,
'taskId': taskId,
'assignedBy': assignedBy,
'completed': completed,
'assignedOn': assignedOn,
'location': location,
'teamSize': task.teamMembers.length,
'teamMembers': teamMembers,
'pendingWork': pendingWork,
};
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => Padding(
padding: MediaQuery.of(ctx).viewInsets,
child: ReportTaskBottomSheet(
taskData: taskData,
onReportSuccess: refreshCallback,
),
),
);
},
);
}
static Widget commentButton({
required BuildContext context,
required dynamic task,
required VoidCallback refreshCallback,
required String parentTaskID,
required String activityId,
required String workAreaId,
}) {
return OutlinedButton.icon(
icon: const Icon(Icons.comment, size: 18, color: Colors.blueAccent),
label: const Text('Comment', style: TextStyle(color: Colors.blueAccent)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.blueAccent),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
textStyle: const TextStyle(fontSize: 14),
),
onPressed: () {
final taskData =
_prepareTaskData(task: task, completed: task.completedTask.toInt());
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => CommentTaskBottomSheet(
taskData: taskData,
taskDataId: parentTaskID,
workAreaId: workAreaId,
activityId: activityId,
onCommentSuccess: () {
refreshCallback();
Navigator.of(context).pop();
},
),
);
},
);
}
static Widget reportActionButton({
required BuildContext context,
required dynamic task,
required int completed,
required VoidCallback refreshCallback,
required String parentTaskID,
required String activityId,
required String workAreaId,
}) {
return OutlinedButton.icon(
icon: const Icon(Icons.report, size: 18, color: Colors.amber),
label: const Text('Take Report Action',
style: TextStyle(color: Colors.amber)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.amber),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
textStyle: const TextStyle(fontSize: 14),
),
onPressed: () {
final taskData = _prepareTaskData(task: task, completed: completed);
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => Padding(
padding: MediaQuery.of(ctx).viewInsets,
child: ReportActionBottomSheet(
taskData: taskData,
taskDataId: parentTaskID,
workAreaId: workAreaId,
activityId: activityId,
onReportSuccess: refreshCallback,
),
),
);
},
);
}
static Map<String, dynamic> _prepareTaskData({
required dynamic task,
required int completed,
}) {
final activityName = task.workItem?.activityMaster?.activityName ?? 'N/A';
final assigned = '${(task.plannedTask - completed)}';
final assignedBy =
"${task.assignedBy.firstName} ${task.assignedBy.lastName ?? ''}";
final assignedOn = DateFormat('yyyy-MM-dd').format(task.assignmentDate);
final taskId = task.id;
final location = [
task.workItem?.workArea?.floor?.building?.name,
task.workItem?.workArea?.floor?.floorName,
task.workItem?.workArea?.areaName,
].where((e) => e != null && e.isNotEmpty).join(' > ');
final teamMembers = task.teamMembers
.map((e) => '${e.firstName} ${e.lastName ?? ''}')
.toList();
final pendingWork =
(task.workItem?.plannedWork ?? 0) - (task.workItem?.completedWork ?? 0);
final taskComments = task.comments.map((comment) {
final isoDate = comment.timestamp.toIso8601String();
final commenterName = comment.commentedBy.firstName.isNotEmpty
? "${comment.commentedBy.firstName} ${comment.commentedBy.lastName ?? ''}"
.trim()
: "Unknown";
return {
'text': comment.comment,
'date': isoDate,
'commentedBy': commenterName,
'preSignedUrls': comment.preSignedUrls,
};
}).toList();
final taskLevelPreSignedUrls = task.reportedPreSignedUrls;
return {
'activity': activityName,
'assigned': assigned,
'taskId': taskId,
'assignedBy': assignedBy,
'completed': completed,
'plannedWork': task.plannedTask.toString(),
'completedWork': completed.toString(),
'assignedOn': assignedOn,
'location': location,
'teamSize': task.teamMembers.length,
'teamMembers': teamMembers,
'pendingWork': pendingWork,
'taskComments': taskComments,
'reportedPreSignedUrls': taskLevelPreSignedUrls,
};
}
}

View File

@ -1,45 +0,0 @@
import 'dart:convert';
import 'package:marco/helpers/services/json_decoder.dart';
import 'package:marco/model/identifier_model.dart';
import 'package:flutter/services.dart';
class TaskListModel extends IdentifierModel {
final String title, description, priority, status;
final DateTime dueDate;
late bool isSelectTask;
TaskListModel(super.id, this.title, this.description, this.priority, this.status, this.dueDate, this.isSelectTask);
static TaskListModel fromJSON(Map<String, dynamic> json) {
JSONDecoder decoder = JSONDecoder(json);
String title = decoder.getString('title');
String description = decoder.getString('description');
String priority = decoder.getString('priority');
String status = decoder.getString('status');
DateTime dueDate = decoder.getDateTime('due_date');
bool isSelectTask = decoder.getBool('isSelectTask');
return TaskListModel(decoder.getId, title, description, priority, status, dueDate, isSelectTask);
}
static List<TaskListModel> listFromJSON(List<dynamic> list) {
return list.map((e) => TaskListModel.fromJSON(e)).toList();
}
static List<TaskListModel>? _dummyList;
static Future<List<TaskListModel>> get dummyList async {
if (_dummyList == null) {
dynamic data = json.decode(await getData());
_dummyList = listFromJSON(data);
}
return _dummyList!;
}
static Future<String> getData() async {
return await rootBundle.loadString('assets/data/task_list.json');
}
}

View File

@ -1,53 +0,0 @@
class WorkStatusResponseModel {
final bool success;
final String message;
final List<WorkStatus> data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
WorkStatusResponseModel({
required this.success,
required this.message,
required this.data,
required this.errors,
required this.statusCode,
required this.timestamp,
});
factory WorkStatusResponseModel.fromJson(Map<String, dynamic> json) {
return WorkStatusResponseModel(
success: json['success'],
message: json['message'],
data: List<WorkStatus>.from(
json['data'].map((item) => WorkStatus.fromJson(item)),
),
errors: json['errors'],
statusCode: json['statusCode'],
timestamp: DateTime.parse(json['timestamp']),
);
}
}
class WorkStatus {
final String id;
final String name;
final String description;
final bool isSystem;
WorkStatus({
required this.id,
required this.name,
required this.description,
required this.isSystem,
});
factory WorkStatus.fromJson(Map<String, dynamic> json) {
return WorkStatus(
id: json['id'],
name: json['name'],
description: json['description'],
isSystem: json['isSystem'],
);
}
}

View File

@ -0,0 +1,74 @@
class ExpenseReportResponse {
final bool success;
final String message;
final List<ExpenseReportData> data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
ExpenseReportResponse({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory ExpenseReportResponse.fromJson(Map<String, dynamic> json) {
return ExpenseReportResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: json['data'] != null
? List<ExpenseReportData>.from(
json['data'].map((x) => ExpenseReportData.fromJson(x)))
: [],
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: DateTime.parse(json['timestamp']),
);
}
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data.map((x) => x.toJson()).toList(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
class ExpenseReportData {
final String monthName;
final int year;
final double total;
final int count;
ExpenseReportData({
required this.monthName,
required this.year,
required this.total,
required this.count,
});
factory ExpenseReportData.fromJson(Map<String, dynamic> json) {
return ExpenseReportData(
monthName: json['monthName'] ?? '',
year: json['year'] ?? 0,
total: json['total'] != null
? (json['total'] is int
? (json['total'] as int).toDouble()
: json['total'] as double)
: 0.0,
count: json['count'] ?? 0,
);
}
Map<String, dynamic> toJson() => {
'monthName': monthName,
'year': year,
'total': total,
'count': count,
};
}

View File

@ -0,0 +1,105 @@
class ExpenseTypeReportResponse {
final bool success;
final String message;
final ExpenseTypeReportData data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
ExpenseTypeReportResponse({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory ExpenseTypeReportResponse.fromJson(Map<String, dynamic> json) {
return ExpenseTypeReportResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: ExpenseTypeReportData.fromJson(json['data'] ?? {}),
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: DateTime.parse(json['timestamp']),
);
}
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
class ExpenseTypeReportData {
final List<ExpenseTypeReportItem> report;
final double totalAmount;
ExpenseTypeReportData({
required this.report,
required this.totalAmount,
});
factory ExpenseTypeReportData.fromJson(Map<String, dynamic> json) {
return ExpenseTypeReportData(
report: json['report'] != null
? List<ExpenseTypeReportItem>.from(
json['report'].map((x) => ExpenseTypeReportItem.fromJson(x)))
: [],
totalAmount: json['totalAmount'] != null
? (json['totalAmount'] is int
? (json['totalAmount'] as int).toDouble()
: json['totalAmount'] as double)
: 0.0,
);
}
Map<String, dynamic> toJson() => {
'report': report.map((x) => x.toJson()).toList(),
'totalAmount': totalAmount,
};
}
class ExpenseTypeReportItem {
final String projectName;
final double totalApprovedAmount;
final double totalPendingAmount;
final double totalRejectedAmount;
final double totalProcessedAmount;
ExpenseTypeReportItem({
required this.projectName,
required this.totalApprovedAmount,
required this.totalPendingAmount,
required this.totalRejectedAmount,
required this.totalProcessedAmount,
});
factory ExpenseTypeReportItem.fromJson(Map<String, dynamic> json) {
double parseAmount(dynamic value) {
if (value == null) return 0.0;
return value is int ? value.toDouble() : value as double;
}
return ExpenseTypeReportItem(
projectName: json['projectName'] ?? '',
totalApprovedAmount: parseAmount(json['totalApprovedAmount']),
totalPendingAmount: parseAmount(json['totalPendingAmount']),
totalRejectedAmount: parseAmount(json['totalRejectedAmount']),
totalProcessedAmount: parseAmount(json['totalProcessedAmount']),
);
}
Map<String, dynamic> toJson() => {
'projectName': projectName,
'totalApprovedAmount': totalApprovedAmount,
'totalPendingAmount': totalPendingAmount,
'totalRejectedAmount': totalRejectedAmount,
'totalProcessedAmount': totalProcessedAmount,
};
}

View File

@ -0,0 +1,74 @@
class ExpenseTypeResponse {
final bool success;
final String message;
final List<ExpenseTypeData> data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
ExpenseTypeResponse({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory ExpenseTypeResponse.fromJson(Map<String, dynamic> json) {
return ExpenseTypeResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: json['data'] != null
? List<ExpenseTypeData>.from(
json['data'].map((x) => ExpenseTypeData.fromJson(x)))
: [],
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: DateTime.parse(json['timestamp']),
);
}
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data.map((x) => x.toJson()).toList(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
class ExpenseTypeData {
final String id;
final String name;
final bool noOfPersonsRequired;
final bool isAttachmentRequried;
final String description;
ExpenseTypeData({
required this.id,
required this.name,
required this.noOfPersonsRequired,
required this.isAttachmentRequried,
required this.description,
});
factory ExpenseTypeData.fromJson(Map<String, dynamic> json) {
return ExpenseTypeData(
id: json['id'] ?? '',
name: json['name'] ?? '',
noOfPersonsRequired: json['noOfPersonsRequired'] ?? false,
isAttachmentRequried: json['isAttachmentRequried'] ?? false,
description: json['description'] ?? '',
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'noOfPersonsRequired': noOfPersonsRequired,
'isAttachmentRequried': isAttachmentRequried,
'description': description,
};
}

View File

@ -0,0 +1,70 @@
class DashboardMonthlyExpenseResponse {
final bool success;
final String message;
final List<MonthlyExpenseData> data;
final dynamic errors;
final int statusCode;
final String timestamp;
DashboardMonthlyExpenseResponse({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory DashboardMonthlyExpenseResponse.fromJson(Map<String, dynamic> json) {
return DashboardMonthlyExpenseResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: (json['data'] as List<dynamic>?)
?.map((e) => MonthlyExpenseData.fromJson(e))
.toList() ??
[],
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: json['timestamp'] ?? '',
);
}
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data.map((e) => e.toJson()).toList(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp,
};
}
class MonthlyExpenseData {
final String monthName;
final int year;
final double total;
final int count;
MonthlyExpenseData({
required this.monthName,
required this.year,
required this.total,
required this.count,
});
factory MonthlyExpenseData.fromJson(Map<String, dynamic> json) {
return MonthlyExpenseData(
monthName: json['monthName'] ?? '',
year: json['year'] ?? 0,
total: (json['total'] ?? 0).toDouble(),
count: json['count'] ?? 0,
);
}
Map<String, dynamic> toJson() => {
'monthName': monthName,
'year': year,
'total': total,
'count': count,
};
}

View File

@ -0,0 +1,169 @@
import 'package:equatable/equatable.dart';
class PendingExpensesResponse extends Equatable {
final bool success;
final String message;
final PendingExpensesData? data;
final dynamic errors;
final int statusCode;
final String timestamp;
const PendingExpensesResponse({
required this.success,
required this.message,
this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory PendingExpensesResponse.fromJson(Map<String, dynamic> json) {
return PendingExpensesResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: json['data'] != null
? PendingExpensesData.fromJson(json['data'])
: null,
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,
};
}
PendingExpensesResponse copyWith({
bool? success,
String? message,
PendingExpensesData? data,
dynamic errors,
int? statusCode,
String? timestamp,
}) {
return PendingExpensesResponse(
success: success ?? this.success,
message: message ?? this.message,
data: data ?? this.data,
errors: errors ?? this.errors,
statusCode: statusCode ?? this.statusCode,
timestamp: timestamp ?? this.timestamp,
);
}
@override
List<Object?> get props => [success, message, data, errors, statusCode, timestamp];
}
class PendingExpensesData extends Equatable {
final ExpenseStatus draft;
final ExpenseStatus reviewPending;
final ExpenseStatus approvePending;
final ExpenseStatus processPending;
final ExpenseStatus submited;
final double totalAmount;
const PendingExpensesData({
required this.draft,
required this.reviewPending,
required this.approvePending,
required this.processPending,
required this.submited,
required this.totalAmount,
});
factory PendingExpensesData.fromJson(Map<String, dynamic> json) {
return PendingExpensesData(
draft: ExpenseStatus.fromJson(json['draft']),
reviewPending: ExpenseStatus.fromJson(json['reviewPending']),
approvePending: ExpenseStatus.fromJson(json['approvePending']),
processPending: ExpenseStatus.fromJson(json['processPending']),
submited: ExpenseStatus.fromJson(json['submited']),
totalAmount: (json['totalAmount'] ?? 0).toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'draft': draft.toJson(),
'reviewPending': reviewPending.toJson(),
'approvePending': approvePending.toJson(),
'processPending': processPending.toJson(),
'submited': submited.toJson(),
'totalAmount': totalAmount,
};
}
PendingExpensesData copyWith({
ExpenseStatus? draft,
ExpenseStatus? reviewPending,
ExpenseStatus? approvePending,
ExpenseStatus? processPending,
ExpenseStatus? submited,
double? totalAmount,
}) {
return PendingExpensesData(
draft: draft ?? this.draft,
reviewPending: reviewPending ?? this.reviewPending,
approvePending: approvePending ?? this.approvePending,
processPending: processPending ?? this.processPending,
submited: submited ?? this.submited,
totalAmount: totalAmount ?? this.totalAmount,
);
}
@override
List<Object?> get props => [
draft,
reviewPending,
approvePending,
processPending,
submited,
totalAmount,
];
}
class ExpenseStatus extends Equatable {
final int count;
final double totalAmount;
const ExpenseStatus({
required this.count,
required this.totalAmount,
});
factory ExpenseStatus.fromJson(Map<String, dynamic> json) {
return ExpenseStatus(
count: json['count'] ?? 0,
totalAmount: (json['totalAmount'] ?? 0).toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'count': count,
'totalAmount': totalAmount,
};
}
ExpenseStatus copyWith({
int? count,
double? totalAmount,
}) {
return ExpenseStatus(
count: count ?? this.count,
totalAmount: totalAmount ?? this.totalAmount,
);
}
@override
List<Object?> get props => [count, totalAmount];
}

View File

@ -1,10 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill;
import 'package:marco/controller/directory/add_comment_controller.dart'; import 'package:marco/controller/directory/add_comment_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart';
class AddCommentBottomSheet extends StatefulWidget { class AddCommentBottomSheet extends StatefulWidget {
final String contactId; final String contactId;
@ -17,120 +14,59 @@ class AddCommentBottomSheet extends StatefulWidget {
class _AddCommentBottomSheetState extends State<AddCommentBottomSheet> { class _AddCommentBottomSheetState extends State<AddCommentBottomSheet> {
late final AddCommentController controller; late final AddCommentController controller;
late final quill.QuillController quillController; final TextEditingController textController = TextEditingController();
bool isSubmitting = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller = Get.put(AddCommentController(contactId: widget.contactId)); controller = Get.put(AddCommentController(contactId: widget.contactId));
quillController = quill.QuillController.basic();
} }
@override @override
void dispose() { void dispose() {
quillController.dispose(); textController.dispose();
Get.delete<AddCommentController>();
super.dispose(); super.dispose();
} }
Future<void> handleSubmit() async {
final noteText = textController.text.trim();
if (noteText.isEmpty) return;
setState(() {
isSubmitting = true;
});
controller.updateNote(noteText);
await controller.submitComment();
if (mounted) {
setState(() {
isSubmitting = false;
});
Get.back(result: true);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SingleChildScrollView( return BaseBottomSheet(
padding: MediaQuery.of(context).viewInsets, title: "Add Note",
child: Container( onCancel: () => Get.back(),
decoration: BoxDecoration( onSubmit: handleSubmit,
color: Theme.of(context).cardColor, isSubmitting: isSubmitting,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), child: TextField(
boxShadow: const [ controller: textController,
BoxShadow( maxLines: null,
color: Colors.black12, minLines: 5,
blurRadius: 12, decoration: InputDecoration(
offset: Offset(0, -2), hintText: "Enter your note here...",
), border: OutlineInputBorder(
], borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
),
),
MySpacing.height(12),
Center(child: MyText.titleMedium("Add Note", fontWeight: 700)),
MySpacing.height(24),
CommentEditorCard(
controller: quillController,
onCancel: () => Get.back(),
onSave: (editorController) async {
final delta = editorController.document.toDelta();
final htmlOutput = _convertDeltaToHtml(delta);
controller.updateNote(htmlOutput);
await controller.submitComment();
},
),
],
), ),
contentPadding: const EdgeInsets.all(12),
), ),
), ),
); );
} }
String _convertDeltaToHtml(dynamic delta) {
final buffer = StringBuffer();
bool inList = false;
for (var op in delta.toList()) {
final data = op.data?.toString() ?? '';
final attr = op.attributes ?? {};
final isListItem = attr.containsKey('list');
final trimmedData = data.trim();
if (isListItem && !inList) {
buffer.write('<ul>');
inList = true;
}
if (!isListItem && inList) {
buffer.write('</ul>');
inList = false;
}
if (isListItem && trimmedData.isEmpty) continue;
if (isListItem) buffer.write('<li>');
if (attr.containsKey('bold')) buffer.write('<strong>');
if (attr.containsKey('italic')) buffer.write('<em>');
if (attr.containsKey('underline')) buffer.write('<u>');
if (attr.containsKey('strike')) buffer.write('<s>');
if (attr.containsKey('link')) buffer.write('<a href="${attr['link']}">');
buffer.write(trimmedData.replaceAll('\n', ''));
if (attr.containsKey('link')) buffer.write('</a>');
if (attr.containsKey('strike')) buffer.write('</s>');
if (attr.containsKey('underline')) buffer.write('</u>');
if (attr.containsKey('italic')) buffer.write('</em>');
if (attr.containsKey('bold')) buffer.write('</strong>');
if (isListItem) {
buffer.write('</li>');
} else if (data.contains('\n')) {
buffer.write('<br>');
}
}
if (inList) buffer.write('</ul>');
return buffer.toString();
}
} }

View File

@ -72,40 +72,41 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
controller.enteredTags.assignAll(c.tags.map((e) => e.name)); controller.enteredTags.assignAll(c.tags.map((e) => e.name));
ever(controller.isInitialized, (bool ready) { ever(controller.isInitialized, (bool ready) {
if (ready) { if (ready) {
final projectIds = c.projectIds; // Buckets - map all
final bucketId = c.bucketIds.firstOrNull; if (c.bucketIds.isNotEmpty) {
final category = c.contactCategory?.name; final names = c.bucketIds.map((id) {
return controller.bucketsMap.entries
if (category != null) controller.selectedCategory.value = category; .firstWhereOrNull((e) => e.value == id)
if (projectIds != null) {
controller.selectedProjects.assignAll(
projectIds
.map((id) => controller.projectsMap.entries
.firstWhereOrNull((e) => e.value == id)
?.key)
.whereType<String>()
.toList(),
);
}
if (bucketId != null) {
final name = controller.bucketsMap.entries
.firstWhereOrNull((e) => e.value == bucketId)
?.key; ?.key;
if (name != null) controller.selectedBucket.value = name; }).whereType<String>().toList();
} controller.selectedBuckets.assignAll(names);
} }
}); // Projects and Category mapping - as before
} else { final projectIds = c.projectIds;
emailCtrls.add(TextEditingController()); if (projectIds != null) {
emailLabels.add('Office'.obs); controller.selectedProjects.assignAll(
phoneCtrls.add(TextEditingController()); projectIds
phoneLabels.add('Work'.obs); .map((id) => controller.projectsMap.entries
} .firstWhereOrNull((e) => e.value == id)
?.key)
.whereType<String>()
.toList(),
);
}
final category = c.contactCategory?.name;
if (category != null) controller.selectedCategory.value = category;
}
});
} else {
showAdvanced.value = false; // Optional
emailCtrls.add(TextEditingController());
emailLabels.add('Office'.obs);
phoneCtrls.add(TextEditingController());
phoneLabels.add('Work'.obs);
} }
}
@override @override
void dispose() { void dispose() {
@ -363,10 +364,125 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
); );
} }
Widget _bucketMultiSelectField() {
return _multiSelectField(
items: controller.buckets
.map((name) => FilterItem(id: name, name: name))
.toList(),
fallback: "Choose Buckets",
selectedValues: controller.selectedBuckets,
);
}
Widget _multiSelectField({
required List<FilterItem> items,
required String fallback,
required RxList<String> selectedValues,
}) {
if (items.isEmpty) return const SizedBox.shrink();
return Obx(() {
final selectedNames = items
.where((f) => selectedValues.contains(f.id))
.map((f) => f.name)
.join(", ");
final displayText = selectedNames.isNotEmpty ? selectedNames : fallback;
return Builder(
builder: (context) {
return GestureDetector(
onTap: () async {
final RenderBox button =
context.findRenderObject() as RenderBox;
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero);
await showMenu(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy + button.size.height,
overlay.size.width - position.dx - button.size.width,
0,
),
items: [
PopupMenuItem(
enabled: false,
child: StatefulBuilder(
builder: (context, setState) {
return SizedBox(
width: 250,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: items.map((f) {
final isChecked = selectedValues.contains(f.id);
return CheckboxListTile(
dense: true,
title: Text(f.name),
value: isChecked,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
side: const BorderSide(color: Colors.black, width: 1.5),
fillColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) {
return Colors.indigo; // selected color
}
return Colors.white; // unselected background
}),
checkColor: Colors.white, // tick color
onChanged: (val) {
if (val == true) {
selectedValues.add(f.id);
} else {
selectedValues.remove(f.id);
}
setState(() {});
},
);
}).toList(),
),
),
);
},
),
),
],
);
},
child: Container(
padding: MySpacing.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: MyText(
displayText,
style: const TextStyle(color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
);
},
);
});
}
void _handleSubmit() { void _handleSubmit() {
bool valid = formKey.currentState?.validate() ?? false; bool valid = formKey.currentState?.validate() ?? false;
if (controller.selectedBucket.value.isEmpty) { if (controller.selectedBuckets.isEmpty) {
bucketError.value = "Bucket is required"; bucketError.value = "Bucket is required";
valid = false; valid = false;
} else { } else {
@ -430,29 +546,14 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
MySpacing.height(16), MySpacing.height(16),
_textField("Organization", orgCtrl, required: true), _textField("Organization", orgCtrl, required: true),
MySpacing.height(16), MySpacing.height(16),
_labelWithStar("Bucket", required: true), _labelWithStar("Buckets", required: true),
MySpacing.height(8), MySpacing.height(8),
Stack( Stack(
children: [ children: [
_popupSelector(controller.selectedBucket, controller.buckets, _bucketMultiSelectField(),
"Choose Bucket"),
Positioned(
left: 0,
right: 0,
top: 56,
child: Obx(() => bucketError.value.isEmpty
? const SizedBox.shrink()
: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(bucketError.value,
style: const TextStyle(
color: Colors.red, fontSize: 12)),
)),
),
], ],
), ),
MySpacing.height(24), MySpacing.height(12),
Obx(() => GestureDetector( Obx(() => GestureDetector(
onTap: () => showAdvanced.toggle(), onTap: () => showAdvanced.toggle(),
child: Row( child: Row(
@ -562,3 +663,9 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
}); });
} }
} }
class FilterItem {
final String id;
final String name;
FilterItem({required this.id, required this.name});
}

View File

@ -2,24 +2,33 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/document/user_document_controller.dart'; import 'package:marco/controller/document/user_document_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/model/document/document_filter_model.dart'; import 'package:marco/model/document/document_filter_model.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/date_range_picker.dart';
class UserDocumentFilterBottomSheet extends StatelessWidget { class UserDocumentFilterBottomSheet extends StatefulWidget {
final String entityId; final String entityId;
final String entityTypeId; final String entityTypeId;
final DocumentController docController = Get.find<DocumentController>();
UserDocumentFilterBottomSheet({ const UserDocumentFilterBottomSheet({
super.key, super.key,
required this.entityId, required this.entityId,
required this.entityTypeId, required this.entityTypeId,
}); });
@override
State<UserDocumentFilterBottomSheet> createState() =>
_UserDocumentFilterBottomSheetState();
}
class _UserDocumentFilterBottomSheetState
extends State<UserDocumentFilterBottomSheet> with UIMixin {
final DocumentController docController = Get.find<DocumentController>();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final filterData = docController.filters.value; final filterData = docController.filters.value;
@ -51,8 +60,8 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
}; };
docController.fetchDocuments( docController.fetchDocuments(
entityTypeId: entityTypeId, entityTypeId: widget.entityTypeId,
entityId: entityId, entityId: widget.entityId,
filter: jsonEncode(combinedFilter), filter: jsonEncode(combinedFilter),
reset: true, reset: true,
); );
@ -76,144 +85,64 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
), ),
), ),
), ),
// --- Date Filter (Uploaded On / Updated On) --- // --- Date Range using Radio Buttons on Same Row ---
_buildField( _buildField(
"Choose Date", "Choose Date",
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Segmented Buttons
Obx(() { Obx(() {
return Container( return Row(
decoration: BoxDecoration( children: [
border: Border.all(color: Colors.grey.shade300), // --- Upload Date ---
borderRadius: BorderRadius.circular(24), Expanded(
), child: Row(
child: Row( children: [
children: [ Radio<bool>(
Expanded( value: true,
child: GestureDetector( groupValue:
onTap: () => docController.isUploadedAt.value,
docController.isUploadedAt.value = true, onChanged: (val) => docController
child: Container( .isUploadedAt.value = val!,
padding: const EdgeInsets.symmetric( activeColor: contentTheme.primary,
vertical: 10),
decoration: BoxDecoration(
color: docController.isUploadedAt.value
? Colors.indigo.shade400
: Colors.transparent,
borderRadius:
const BorderRadius.horizontal(
left: Radius.circular(24),
),
),
child: Center(
child: MyText(
"Upload Date",
style: MyTextStyle.bodyMedium(
color:
docController.isUploadedAt.value
? Colors.white
: Colors.black87,
fontWeight: 600,
),
),
),
), ),
), MyText("Upload Date"),
],
), ),
Expanded( ),
child: GestureDetector( // --- Update Date ---
onTap: () => docController Expanded(
.isUploadedAt.value = false, child: Row(
child: Container( children: [
padding: const EdgeInsets.symmetric( Radio<bool>(
vertical: 10), value: false,
decoration: BoxDecoration( groupValue:
color: !docController.isUploadedAt.value docController.isUploadedAt.value,
? Colors.indigo.shade400 onChanged: (val) => docController
: Colors.transparent, .isUploadedAt.value = val!,
borderRadius: activeColor: contentTheme.primary,
const BorderRadius.horizontal(
right: Radius.circular(24),
),
),
child: Center(
child: MyText(
"Update Date",
style: MyTextStyle.bodyMedium(
color: !docController
.isUploadedAt.value
? Colors.white
: Colors.black87,
fontWeight: 600,
),
),
),
), ),
), MyText("Update Date"),
],
), ),
], ),
), ],
); );
}), }),
MySpacing.height(12), MySpacing.height(12),
// Date Range // --- Date Range Picker ---
Row( DateRangePickerWidget(
children: [ startDate: docController.startDate,
Expanded( endDate: docController.endDate,
child: Obx(() { startLabel: "From Date",
return _dateButton( endLabel: "To Date",
label: docController.startDate.value == null onDateRangeSelected: (start, end) {
? 'From Date' if (start != null && end != null) {
: DateTimeUtils.formatDate( docController.startDate.value = start;
DateTime.parse( docController.endDate.value = end;
docController.startDate.value!), }
'dd MMM yyyy', },
),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (picked != null) {
docController.startDate.value =
picked.toIso8601String();
}
},
);
}),
),
MySpacing.width(12),
Expanded(
child: Obx(() {
return _dateButton(
label: docController.endDate.value == null
? 'To Date'
: DateTimeUtils.formatDate(
DateTime.parse(
docController.endDate.value!),
'dd MMM yyyy',
),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (picked != null) {
docController.endDate.value =
picked.toIso8601String();
}
},
);
}),
),
],
), ),
], ],
), ),
@ -251,7 +180,6 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
Obx(() { Obx(() {
return Container( return Container(
padding: MySpacing.all(12), padding: MySpacing.all(12),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@ -263,8 +191,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
groupValue: docController.isVerified.value, groupValue: docController.isVerified.value,
onChanged: (val) => onChanged: (val) =>
docController.isVerified.value = val, docController.isVerified.value = val,
activeColor: activeColor: contentTheme.primary,
Colors.indigo,
materialTapTargetSize: materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap, MaterialTapTargetSize.shrinkWrap,
), ),
@ -279,7 +206,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
groupValue: docController.isVerified.value, groupValue: docController.isVerified.value,
onChanged: (val) => onChanged: (val) =>
docController.isVerified.value = val, docController.isVerified.value = val,
activeColor: Colors.indigo, activeColor: contentTheme.primary,
materialTapTargetSize: materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap, MaterialTapTargetSize.shrinkWrap,
), ),
@ -294,7 +221,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
groupValue: docController.isVerified.value, groupValue: docController.isVerified.value,
onChanged: (val) => onChanged: (val) =>
docController.isVerified.value = val, docController.isVerified.value = val,
activeColor: Colors.indigo, activeColor: contentTheme.primary,
materialTapTargetSize: materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap, MaterialTapTargetSize.shrinkWrap,
), ),
@ -391,7 +318,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
(states) { (states) {
if (states if (states
.contains(MaterialState.selected)) { .contains(MaterialState.selected)) {
return Colors.indigo; // checked Indigo return contentTheme.primary;
} }
return Colors.white; // unchecked White return Colors.white; // unchecked White
}, },
@ -454,31 +381,4 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
], ],
); );
} }
Widget _dateButton({required String label, required VoidCallback onTap}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: MySpacing.xy(16, 12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
children: [
const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
MySpacing.width(8),
Expanded(
child: MyText(
label,
style: MyTextStyle.bodyMedium(),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
} }

View File

@ -5,7 +5,6 @@ import 'package:intl/intl.dart';
import 'package:marco/controller/employee/add_employee_controller.dart'; import 'package:marco/controller/employee/add_employee_controller.dart';
import 'package:marco/controller/employee/employees_screen_controller.dart'; import 'package:marco/controller/employee/employees_screen_controller.dart';
import 'package:marco/controller/tenant/organization_selection_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
@ -24,8 +23,6 @@ class AddEmployeeBottomSheet extends StatefulWidget {
class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
with UIMixin { with UIMixin {
late final AddEmployeeController _controller; late final AddEmployeeController _controller;
final OrganizationController _organizationController =
Get.put(OrganizationController());
// Local UI state // Local UI state
bool _hasApplicationAccess = false; bool _hasApplicationAccess = false;
@ -39,51 +36,56 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_orgFieldController = TextEditingController();
_joiningDateController = TextEditingController();
_genderController = TextEditingController();
_roleController = TextEditingController();
_controller = Get.put( _controller = Get.put(AddEmployeeController(), tag: UniqueKey().toString());
AddEmployeeController(),
// Unique tag to avoid clashes, but stable for this widget instance
tag: UniqueKey().toString(),
);
_orgFieldController = TextEditingController(text: '');
_joiningDateController = TextEditingController(text: '');
_genderController = TextEditingController(text: '');
_roleController = TextEditingController(text: '');
// Prefill when editing
if (widget.employeeData != null) { if (widget.employeeData != null) {
_controller.editingEmployeeData = widget.employeeData; _controller.editingEmployeeData = widget.employeeData;
_controller.prefillFields(); _controller.prefillFields();
final orgId = widget.employeeData!['organizationId']; // Prepopulate hasApplicationAccess and email
if (orgId != null) { _hasApplicationAccess =
_controller.selectedOrganizationId = orgId; widget.employeeData?['hasApplicationAccess'] ?? false;
final selectedOrg = _organizationController.organizations final email = widget.employeeData?['email'];
.firstWhereOrNull((o) => o.id == orgId); if (email != null && email.toString().isNotEmpty) {
if (selectedOrg != null) { _controller.basicValidator.getController('email')?.text =
_organizationController.selectOrganization(selectedOrg); email.toString();
_orgFieldController.text = selectedOrg.name;
}
} }
// Trigger UI rebuild to reflect email & checkbox
setState(() {});
// Joining date
if (_controller.joiningDate != null) { if (_controller.joiningDate != null) {
_joiningDateController.text = _joiningDateController.text =
DateFormat('dd MMM yyyy').format(_controller.joiningDate!); DateFormat('dd MMM yyyy').format(_controller.joiningDate!);
} }
// Gender
if (_controller.selectedGender != null) { if (_controller.selectedGender != null) {
_genderController.text = _genderController.text =
_controller.selectedGender!.name.capitalizeFirst ?? ''; _controller.selectedGender!.name.capitalizeFirst ?? '';
} }
final roleName = _controller.roles.firstWhereOrNull( // Role
(r) => r['id'] == _controller.selectedRoleId)?['name'] ?? _controller.fetchRoles().then((_) {
''; if (_controller.selectedRoleId != null) {
_roleController.text = roleName; final roleName = _controller.roles.firstWhereOrNull(
(r) => r['id'] == _controller.selectedRoleId,
)?['name'];
if (roleName != null) {
_roleController.text = roleName;
}
_controller.update();
}
});
} else { } else {
_orgFieldController.text = _organizationController.currentSelection; _controller.fetchRoles();
} }
} }
@ -102,7 +104,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
init: _controller, init: _controller,
builder: (_) { builder: (_) {
// Keep org field in sync with controller selection // Keep org field in sync with controller selection
_orgFieldController.text = _organizationController.currentSelection;
return BaseBottomSheet( return BaseBottomSheet(
title: widget.employeeData != null ? 'Edit Employee' : 'Add Employee', title: widget.employeeData != null ? 'Edit Employee' : 'Add Employee',
@ -135,30 +136,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
_controller.basicValidator.getValidation('last_name'), _controller.basicValidator.getValidation('last_name'),
), ),
MySpacing.height(16), MySpacing.height(16),
_sectionLabel('Organization'),
MySpacing.height(8),
GestureDetector(
onTap: () => _showOrganizationPopup(context),
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: _orgFieldController,
validator: (val) {
if (val == null ||
val.trim().isEmpty ||
val == 'All Organizations') {
return 'Organization is required';
}
return null;
},
decoration:
_inputDecoration('Select Organization').copyWith(
suffixIcon: const Icon(Icons.expand_more),
),
),
),
),
MySpacing.height(24),
_sectionLabel('Application Access'), _sectionLabel('Application Access'),
Row( Row(
children: [ children: [
@ -333,8 +310,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
return null; return null;
}, },
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
decoration: _inputDecoration('e.g., john.doe@example.com').copyWith( decoration: _inputDecoration('e.g., john.doe@example.com').copyWith(),
),
), ),
], ],
); );
@ -466,7 +442,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
context: context, context: context,
initialDate: _controller.joiningDate ?? DateTime.now(), initialDate: _controller.joiningDate ?? DateTime.now(),
firstDate: DateTime(2000), firstDate: DateTime(2000),
lastDate: DateTime(2100), lastDate: DateTime.now(),
); );
if (picked != null) { if (picked != null) {
@ -479,13 +455,20 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
Future<void> _handleSubmit() async { Future<void> _handleSubmit() async {
final isValid = final isValid =
_controller.basicValidator.formKey.currentState?.validate() ?? false; _controller.basicValidator.formKey.currentState?.validate() ?? false;
if (_controller.joiningDate != null &&
_controller.joiningDate!.isAfter(DateTime.now())) {
showAppSnackbar(
title: 'Invalid Date',
message: 'Joining Date cannot be in the future.',
type: SnackbarType.warning,
);
return;
}
if (!isValid || if (!isValid ||
_controller.joiningDate == null || _controller.joiningDate == null ||
_controller.selectedGender == null || _controller.selectedGender == null ||
_controller.selectedRoleId == null || _controller.selectedRoleId == null) {
_organizationController.currentSelection.isEmpty ||
_organizationController.currentSelection == 'All Organizations') {
showAppSnackbar( showAppSnackbar(
title: 'Missing Fields', title: 'Missing Fields',
message: 'Please complete all required fields.', message: 'Please complete all required fields.',
@ -514,40 +497,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
} }
} }
void _showOrganizationPopup(BuildContext context) async {
final orgs = _organizationController.organizations;
if (orgs.isEmpty) {
showAppSnackbar(
title: 'No Organizations',
message: 'No organizations available to select.',
type: SnackbarType.warning,
);
return;
}
final selected = await showMenu<String>(
context: context,
position: _popupMenuPosition(context),
items: orgs
.map(
(org) => PopupMenuItem<String>(
value: org.id,
child: Text(org.name),
),
)
.toList(),
);
if (selected != null && selected.trim().isNotEmpty) {
final chosen = orgs.firstWhere((e) => e.id == selected);
_organizationController.selectOrganization(chosen);
_controller.selectedOrganizationId = chosen.id;
_orgFieldController.text = chosen.name;
_controller.update();
}
}
void _showGenderPopup(BuildContext context) async { void _showGenderPopup(BuildContext context) async {
final selected = await showMenu<Gender>( final selected = await showMenu<Gender>(
context: context, context: context,

View File

@ -12,15 +12,17 @@ class EmployeeDetailsModel {
final String phoneNumber; final String phoneNumber;
final String? emergencyPhoneNumber; final String? emergencyPhoneNumber;
final String? emergencyContactPerson; final String? emergencyContactPerson;
final String? aadharNumber;
final bool isActive; final bool isActive;
final String? panNumber; final bool isRootUser;
final String? photo;
final String? applicationUserId;
final String jobRoleId;
final bool isSystem; final bool isSystem;
final String jobRole; final String jobRole;
final String jobRoleId;
final String? photo;
final String? applicationUserId;
final bool hasApplicationAccess;
final String? organizationId;
final String? aadharNumber;
final String? panNumber;
EmployeeDetailsModel({ EmployeeDetailsModel({
required this.id, required this.id,
required this.firstName, required this.firstName,
@ -35,14 +37,17 @@ class EmployeeDetailsModel {
required this.phoneNumber, required this.phoneNumber,
this.emergencyPhoneNumber, this.emergencyPhoneNumber,
this.emergencyContactPerson, this.emergencyContactPerson,
this.aadharNumber,
required this.isActive, required this.isActive,
this.panNumber, required this.isRootUser,
this.photo,
this.applicationUserId,
required this.jobRoleId,
required this.isSystem, required this.isSystem,
required this.jobRole, required this.jobRole,
required this.jobRoleId,
this.photo,
this.applicationUserId,
required this.hasApplicationAccess,
this.organizationId,
this.aadharNumber,
this.panNumber,
}); });
factory EmployeeDetailsModel.fromJson(Map<String, dynamic> json) { factory EmployeeDetailsModel.fromJson(Map<String, dynamic> json) {
@ -60,24 +65,20 @@ class EmployeeDetailsModel {
phoneNumber: json['phoneNumber'], phoneNumber: json['phoneNumber'],
emergencyPhoneNumber: json['emergencyPhoneNumber'], emergencyPhoneNumber: json['emergencyPhoneNumber'],
emergencyContactPerson: json['emergencyContactPerson'], emergencyContactPerson: json['emergencyContactPerson'],
aadharNumber: json['aadharNumber'],
isActive: json['isActive'], isActive: json['isActive'],
panNumber: json['panNumber'], isRootUser: json['isRootUser'],
photo: json['photo'],
applicationUserId: json['applicationUserId'],
jobRoleId: json['jobRoleId'],
isSystem: json['isSystem'], isSystem: json['isSystem'],
jobRole: json['jobRole'], jobRole: json['jobRole'],
jobRoleId: json['jobRoleId'],
photo: json['photo'],
applicationUserId: json['applicationUserId'],
hasApplicationAccess: json['hasApplicationAccess'],
organizationId: json['organizationId'],
aadharNumber: json['aadharNumber'],
panNumber: json['panNumber'],
); );
} }
static DateTime? _parseDate(String? dateStr) {
if (dateStr == null || dateStr == "0001-01-01T00:00:00") {
return null;
}
return DateTime.tryParse(dateStr);
}
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'id': id, 'id': id,
@ -93,14 +94,24 @@ class EmployeeDetailsModel {
'phoneNumber': phoneNumber, 'phoneNumber': phoneNumber,
'emergencyPhoneNumber': emergencyPhoneNumber, 'emergencyPhoneNumber': emergencyPhoneNumber,
'emergencyContactPerson': emergencyContactPerson, 'emergencyContactPerson': emergencyContactPerson,
'aadharNumber': aadharNumber,
'isActive': isActive, 'isActive': isActive,
'panNumber': panNumber, 'isRootUser': isRootUser,
'photo': photo,
'applicationUserId': applicationUserId,
'jobRoleId': jobRoleId,
'isSystem': isSystem, 'isSystem': isSystem,
'jobRole': jobRole, 'jobRole': jobRole,
'jobRoleId': jobRoleId,
'photo': photo,
'applicationUserId': applicationUserId,
'hasApplicationAccess': hasApplicationAccess,
'organizationId': organizationId,
'aadharNumber': aadharNumber,
'panNumber': panNumber,
}; };
} }
static DateTime? _parseDate(String? dateStr) {
if (dateStr == null || dateStr == "0001-01-01T00:00:00") {
return null;
}
return DateTime.tryParse(dateStr);
}
} }

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/controller/expense/add_expense_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/payment_types_model.dart';
import 'package:marco/model/expense/employee_selector_bottom_sheet.dart'; import 'package:marco/model/expense/employee_selector_bottom_sheet.dart';
@ -40,7 +41,8 @@ class _AddExpenseBottomSheet extends StatefulWidget {
State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState(); State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState();
} }
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
with UIMixin {
final AddExpenseController controller = Get.put(AddExpenseController()); final AddExpenseController controller = Get.put(AddExpenseController());
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
@ -326,8 +328,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
CustomTextField( CustomTextField(
controller: controller, controller: controller,
hint: hint ?? "", hint: hint ?? "",
keyboardType: keyboardType: keyboardType ?? TextInputType.text,
keyboardType ?? TextInputType.text,
validator: validator, validator: validator,
maxLines: maxLines, maxLines: maxLines,
), ),
@ -426,39 +427,66 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SectionTitle( const SectionTitle(
icon: Icons.attach_file, title: "Attachments", requiredField: true), icon: Icons.attach_file,
MySpacing.height(6), title: "Attachments",
AttachmentsSection( requiredField: true,
attachments: controller.attachments, ),
existingAttachments: controller.existingAttachments, MySpacing.height(10),
onRemoveNew: controller.removeAttachment, Obx(() {
onRemoveExisting: (item) async { if (controller.isProcessingAttachment.value) {
await showDialog( return Center(
context: context, child: Column(
barrierDismissible: false, children: [
builder: (_) => ConfirmDialog( CircularProgressIndicator(
title: "Remove Attachment", color: contentTheme.primary,
message: "Are you sure you want to remove this attachment?", ),
confirmText: "Remove", const SizedBox(height: 8),
icon: Icons.delete, Text(
confirmColor: Colors.redAccent, "Processing image, please wait...",
onConfirm: () async { style: TextStyle(
final index = controller.existingAttachments.indexOf(item); fontSize: 14,
if (index != -1) { color: contentTheme.primary,
controller.existingAttachments[index]['isActive'] = false; ),
controller.existingAttachments.refresh(); ),
} ],
showAppSnackbar(
title: 'Removed',
message: 'Attachment has been removed.',
type: SnackbarType.success,
);
},
), ),
); );
}, }
onAdd: controller.pickAttachments,
), return AttachmentsSection(
attachments: controller.attachments,
existingAttachments: controller.existingAttachments,
onRemoveNew: controller.removeAttachment,
controller: controller,
onRemoveExisting: (item) async {
await showDialog(
context: context,
barrierDismissible: false,
builder: (_) => ConfirmDialog(
title: "Remove Attachment",
message: "Are you sure you want to remove this attachment?",
confirmText: "Remove",
icon: Icons.delete,
confirmColor: Colors.redAccent,
onConfirm: () async {
final index = controller.existingAttachments.indexOf(item);
if (index != -1) {
controller.existingAttachments[index]['isActive'] = false;
controller.existingAttachments.refresh();
}
showAppSnackbar(
title: 'Removed',
message: 'Attachment has been removed.',
type: SnackbarType.success,
);
Navigator.pop(context);
},
),
);
},
onAdd: controller.pickAttachments,
);
}),
], ],
); );
} }

View File

@ -157,20 +157,29 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
decoration: _inputDecoration("Enter transaction ID"), decoration: _inputDecoration("Enter transaction ID"),
), ),
MySpacing.height(16), MySpacing.height(16),
MySpacing.height(16),
MyText.labelMedium("Reimbursement Date"), MyText.labelMedium("Reimbursement Date"),
MySpacing.height(8), MySpacing.height(8),
GestureDetector( GestureDetector(
onTap: () async { onTap: () async {
// Parse transaction date from controller
final transactionDate = DateTime.tryParse(
controller.expense.value?.transactionDate ?? '');
final today = DateTime.now();
// Fallback if transactionDate is null
final firstDate = transactionDate ?? DateTime(2020);
final lastDate = today;
final picked = await showDatePicker( final picked = await showDatePicker(
context: context, context: context,
initialDate: dateStr.value.isEmpty initialDate: today.isBefore(firstDate) ? firstDate : today,
? DateTime.now() firstDate: firstDate,
: DateFormat('yyyy-MM-dd').parse(dateStr.value), lastDate: lastDate,
firstDate: DateTime(2020),
lastDate: DateTime(2100),
); );
if (picked != null) { if (picked != null) {
dateStr.value = DateFormat('yyyy-MM-dd').format(picked); dateStr.value = DateFormat('dd MMM yyyy').format(picked);
} }
}, },
child: AbsorbPointer( child: AbsorbPointer(

View File

@ -0,0 +1,519 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/finance/add_payment_request_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/validators.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
Future<T?> showPaymentRequestBottomSheet<T>({
bool isEdit = false,
Map<String, dynamic>? existingData,
VoidCallback? onUpdated,
}) {
return Get.bottomSheet<T>(
_PaymentRequestBottomSheet(
isEdit: isEdit,
existingData: existingData,
onUpdated: onUpdated,
),
isScrollControlled: true,
);
}
class _PaymentRequestBottomSheet extends StatefulWidget {
final bool isEdit;
final Map<String, dynamic>? existingData;
final VoidCallback? onUpdated;
const _PaymentRequestBottomSheet({
this.isEdit = false,
this.existingData,
this.onUpdated,
});
@override
State<_PaymentRequestBottomSheet> createState() =>
_PaymentRequestBottomSheetState();
}
class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
with UIMixin {
final controller = Get.put(AddPaymentRequestController());
final _formKey = GlobalKey<FormState>();
final _projectDropdownKey = GlobalKey();
final _categoryDropdownKey = GlobalKey();
final _currencyDropdownKey = GlobalKey();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (widget.isEdit && widget.existingData != null) {
final data = widget.existingData!;
// 🧩 Prefill basic text fields
controller.titleController.text = data["title"] ?? "";
controller.amountController.text = data["amount"]?.toString() ?? "";
controller.descriptionController.text = data["description"] ?? "";
controller.dueDateController.text =
data["dueDate"]?.toString().split(" ")[0] ?? "";
// 🧩 Prefill dropdowns & toggles
controller.selectedProject.value = {
'id': data["projectId"],
'name': data["projectName"],
};
controller.selectedPayee.value = data["payee"] ?? "";
controller.isAdvancePayment.value = data["isAdvancePayment"] ?? false;
// 🕒 Wait until categories & currencies are loaded before setting them
everAll([
controller.categories,
controller.currencies,
], (_) {
controller.selectedCategory.value = controller.categories
.firstWhereOrNull((c) => c.id == data["expenseCategoryId"]);
controller.selectedCurrency.value = controller.currencies
.firstWhereOrNull((c) => c.id == data["currencyId"]);
});
// 🖇 Attachments - Safe parsing (avoids null or wrong type)
final attachmentsData = data["attachments"];
if (attachmentsData != null &&
attachmentsData is List &&
attachmentsData.isNotEmpty) {
final attachments = attachmentsData
.whereType<Map<String, dynamic>>()
.map((a) => {
"id": a["id"],
"fileName": a["fileName"],
"url": a["url"],
"thumbUrl": a["thumbUrl"],
"fileSize": a["fileSize"] ?? 0,
"contentType": a["contentType"] ?? "",
})
.toList();
controller.existingAttachments.assignAll(attachments);
} else {
controller.existingAttachments.clear();
}
}
});
}
@override
Widget build(BuildContext context) {
return Obx(() => Form(
key: _formKey,
child: BaseBottomSheet(
title: widget.isEdit
? "Edit Payment Request"
: "Create Payment Request",
isSubmitting: controller.isSubmitting.value,
onCancel: Get.back,
onSubmit: () async {
if (_formKey.currentState!.validate() && _validateSelections()) {
final success = await controller.submitPaymentRequest();
if (success) {
Get.back();
if (widget.onUpdated != null) widget.onUpdated!();
showAppSnackbar(
title: "Success",
message: widget.isEdit
? "Payment request updated successfully!"
: "Payment request created successfully!",
type: SnackbarType.success,
);
}
}
},
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDropdown(
"Select Project",
Icons.work_outline,
controller.selectedProject.value?['name'] ??
"Select Project",
controller.globalProjects,
(p) => p['name'],
controller.selectProject,
key: _projectDropdownKey),
_gap(),
_buildDropdown(
"Expense Category",
Icons.category_outlined,
controller.selectedCategory.value?.name ??
"Select Category",
controller.categories,
(c) => c.name,
controller.selectCategory,
key: _categoryDropdownKey),
_gap(),
_buildTextField(
"Title", Icons.title_outlined, controller.titleController,
hint: "Enter title", validator: Validators.requiredField),
_gap(),
_buildRadio("Is Advance Payment", Icons.attach_money_outlined,
controller.isAdvancePayment, ["Yes", "No"]),
_gap(),
_buildDueDateField(),
_gap(),
_buildTextField("Amount", Icons.currency_rupee,
controller.amountController,
hint: "Enter Amount",
keyboardType: TextInputType.number,
validator: (v) => (v != null &&
v.isNotEmpty &&
double.tryParse(v) != null)
? null
: "Enter valid amount"),
_gap(),
_buildPayeeAutocompleteField(),
_gap(),
_buildDropdown(
"Currency",
Icons.monetization_on_outlined,
controller.selectedCurrency.value?.currencyName ??
"Select Currency",
controller.currencies,
(c) => c.currencyName,
controller.selectCurrency,
key: _currencyDropdownKey),
_gap(),
_buildTextField("Description", Icons.description_outlined,
controller.descriptionController,
hint: "Enter description",
maxLines: 3,
validator: Validators.requiredField),
_gap(),
_buildAttachmentsSection(),
],
),
),
),
));
}
// ---------------- Helper Widgets ----------------
Widget _buildDropdown<T>(String title, IconData icon, String value,
List<T> options, String Function(T) getLabel, ValueChanged<T> onSelected,
{required GlobalKey key}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(icon: icon, title: title, requiredField: true),
MySpacing.height(6),
DropdownTile(
key: key,
title: value,
onTap: () => _showOptionList(options, getLabel, onSelected, key)),
],
);
}
Widget _buildTextField(
String title, IconData icon, TextEditingController controller,
{String? hint,
TextInputType? keyboardType,
FormFieldValidator<String>? validator,
int maxLines = 1}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(
icon: icon, title: title, requiredField: validator != null),
MySpacing.height(6),
CustomTextField(
controller: controller,
hint: hint ?? "",
keyboardType: keyboardType ?? TextInputType.text,
validator: validator,
maxLines: maxLines,
),
],
);
}
Widget _buildRadio(
String title, IconData icon, RxBool controller, List<String> labels) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 20),
const SizedBox(width: 6),
Text(title,
style:
const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
],
),
MySpacing.height(6),
Obx(() => Row(
children: labels.asMap().entries.map((entry) {
final i = entry.key;
final label = entry.value;
final value = i == 0;
return Expanded(
child: RadioListTile<bool>(
contentPadding: EdgeInsets.zero,
title: Text(label),
value: value,
groupValue: controller.value,
activeColor: contentTheme.primary,
onChanged: (val) =>
val != null ? controller.value = val : null,
),
);
}).toList(),
)),
],
);
}
Widget _buildDueDateField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(
icon: Icons.calendar_today,
title: "Due To Date",
requiredField: true),
MySpacing.height(6),
GestureDetector(
onTap: () => controller.pickDueDate(context),
child: AbsorbPointer(
child: TextFormField(
controller: controller.dueDateController,
decoration: InputDecoration(
hintText: "Select Due Date",
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),
),
),
validator: (_) => controller.selectedDueDate.value == null
? "Please select a due date"
: null,
),
),
),
],
);
}
Widget _buildPayeeAutocompleteField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(
icon: Icons.person_outline, title: "Payee", requiredField: true),
const SizedBox(height: 6),
Autocomplete<String>(
optionsBuilder: (textEditingValue) {
final query = textEditingValue.text.toLowerCase();
return query.isEmpty
? const Iterable<String>.empty()
: controller.payees
.where((p) => p.toLowerCase().contains(query));
},
displayStringForOption: (option) => option,
fieldViewBuilder:
(context, fieldController, focusNode, onFieldSubmitted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (fieldController.text != controller.selectedPayee.value) {
fieldController.text = controller.selectedPayee.value;
fieldController.selection = TextSelection.fromPosition(
TextPosition(offset: fieldController.text.length));
}
});
return TextFormField(
controller: fieldController,
focusNode: focusNode,
decoration: InputDecoration(
hintText: "Type or select payee",
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),
),
),
validator: (v) =>
v == null || v.trim().isEmpty ? "Please enter payee" : null,
onChanged: (val) => controller.selectedPayee.value = val,
);
},
onSelected: (selection) => controller.selectedPayee.value = selection,
optionsViewBuilder: (context, onSelected, options) => Material(
color: Colors.white,
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200, minWidth: 300),
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: options.length,
itemBuilder: (_, index) => InkWell(
onTap: () => onSelected(options.elementAt(index)),
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 10, horizontal: 12),
child: Text(options.elementAt(index),
style: const TextStyle(fontSize: 14)),
),
),
),
),
),
),
],
);
}
Widget _buildAttachmentsSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(
icon: Icons.attach_file, title: "Attachments", requiredField: true),
MySpacing.height(10),
Obx(() {
if (controller.isProcessingAttachment.value) {
return Center(
child: Column(
children: [
CircularProgressIndicator(color: contentTheme.primary),
const SizedBox(height: 8),
Text("Processing image, please wait...",
style:
TextStyle(fontSize: 14, color: contentTheme.primary)),
],
),
);
}
return AttachmentsSection(
attachments: controller.attachments,
existingAttachments: controller.existingAttachments,
onRemoveNew: controller.removeAttachment,
controller: controller,
onRemoveExisting: (item) async {
await showDialog(
context: context,
barrierDismissible: false,
builder: (_) => ConfirmDialog(
title: "Remove Attachment",
message: "Are you sure you want to remove this attachment?",
confirmText: "Remove",
icon: Icons.delete,
confirmColor: Colors.redAccent,
onConfirm: () async {
final index = controller.existingAttachments.indexOf(item);
if (index != -1) {
controller.existingAttachments[index]['isActive'] = false;
controller.existingAttachments.refresh();
}
showAppSnackbar(
title: 'Removed',
message: 'Attachment has been removed.',
type: SnackbarType.success);
Navigator.pop(context);
},
),
);
},
onAdd: controller.pickAttachments,
);
}),
],
);
}
Widget _gap([double h = 16]) => MySpacing.height(h);
Future<void> _showOptionList<T>(List<T> options, String Function(T) getLabel,
ValueChanged<T> onSelected, GlobalKey key) async {
if (options.isEmpty) {
_showError("No options available");
return;
}
if (key.currentContext == null) {
final selected = await showDialog<T>(
context: context,
builder: (_) => SimpleDialog(
children: options
.map((opt) => SimpleDialogOption(
onPressed: () => Navigator.pop(context, opt),
child: Text(getLabel(opt)),
))
.toList(),
),
);
if (selected != null) onSelected(selected);
return;
}
final RenderBox button =
key.currentContext!.findRenderObject() as RenderBox;
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero, ancestor: overlay);
final selected = await showMenu<T>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy + button.size.height,
overlay.size.width - position.dx - button.size.width,
0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
items: options
.map(
(opt) => PopupMenuItem<T>(value: opt, child: Text(getLabel(opt))))
.toList(),
);
if (selected != null) onSelected(selected);
}
bool _validateSelections() {
if (controller.selectedProject.value == null ||
controller.selectedProject.value!['id'].toString().isEmpty) {
return _showError("Please select a project");
}
if (controller.selectedCategory.value == null) {
return _showError("Please select a category");
}
if (controller.selectedPayee.value.isEmpty) {
return _showError("Please select a payee");
}
if (controller.selectedCurrency.value == null) {
return _showError("Please select currency");
}
return true;
}
bool _showError(String msg) {
showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error);
return false;
}
}

View File

@ -0,0 +1,77 @@
class CurrencyListResponse {
final bool success;
final String message;
final List<Currency> data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
CurrencyListResponse({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory CurrencyListResponse.fromJson(Map<String, dynamic> json) {
return CurrencyListResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: (json['data'] as List<dynamic>? ?? [])
.map((e) => Currency.fromJson(e))
.toList(),
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: DateTime.parse(json['timestamp']),
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data.map((e) => e.toJson()).toList(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
}
class Currency {
final String id;
final String currencyCode;
final String currencyName;
final String symbol;
final bool isActive;
Currency({
required this.id,
required this.currencyCode,
required this.currencyName,
required this.symbol,
required this.isActive,
});
factory Currency.fromJson(Map<String, dynamic> json) {
return Currency(
id: json['id'] ?? '',
currencyCode: json['currencyCode'] ?? '',
currencyName: json['currencyName'] ?? '',
symbol: json['symbol'] ?? '',
isActive: json['isActive'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'currencyCode': currencyCode,
'currencyName': currencyName,
'symbol': symbol,
'isActive': isActive,
};
}
}

View File

@ -1,12 +1,12 @@
class ServiceListResponse { class ExpenseCategoryResponse {
final bool success; final bool success;
final String message; final String message;
final List<Service> data; final List<ExpenseCategory> data;
final dynamic errors; final dynamic errors;
final int statusCode; final int statusCode;
final String timestamp; final DateTime timestamp;
ServiceListResponse({ ExpenseCategoryResponse({
required this.success, required this.success,
required this.message, required this.message,
required this.data, required this.data,
@ -15,17 +15,16 @@ class ServiceListResponse {
required this.timestamp, required this.timestamp,
}); });
factory ServiceListResponse.fromJson(Map<String, dynamic> json) { factory ExpenseCategoryResponse.fromJson(Map<String, dynamic> json) {
return ServiceListResponse( return ExpenseCategoryResponse(
success: json['success'] ?? false, success: json['success'] ?? false,
message: json['message'] ?? '', message: json['message'] ?? '',
data: (json['data'] as List<dynamic>?) data: (json['data'] as List<dynamic>? ?? [])
?.map((e) => Service.fromJson(e)) .map((e) => ExpenseCategory.fromJson(e))
.toList() ?? .toList(),
[],
errors: json['errors'], errors: json['errors'],
statusCode: json['statusCode'] ?? 0, statusCode: json['statusCode'] ?? 0,
timestamp: json['timestamp'] ?? '', timestamp: DateTime.parse(json['timestamp']),
); );
} }
@ -36,33 +35,33 @@ class ServiceListResponse {
'data': data.map((e) => e.toJson()).toList(), 'data': data.map((e) => e.toJson()).toList(),
'errors': errors, 'errors': errors,
'statusCode': statusCode, 'statusCode': statusCode,
'timestamp': timestamp, 'timestamp': timestamp.toIso8601String(),
}; };
} }
} }
class Service { class ExpenseCategory {
final String id; final String id;
final String name; final String name;
final bool noOfPersonsRequired;
final bool isAttachmentRequried;
final String description; final String description;
final bool isSystem;
final bool isActive;
Service({ ExpenseCategory({
required this.id, required this.id,
required this.name, required this.name,
required this.noOfPersonsRequired,
required this.isAttachmentRequried,
required this.description, required this.description,
required this.isSystem,
required this.isActive,
}); });
factory Service.fromJson(Map<String, dynamic> json) { factory ExpenseCategory.fromJson(Map<String, dynamic> json) {
return Service( return ExpenseCategory(
id: json['id'] ?? '', id: json['id'] ?? '',
name: json['name'] ?? '', name: json['name'] ?? '',
noOfPersonsRequired: json['noOfPersonsRequired'] ?? false,
isAttachmentRequried: json['isAttachmentRequried'] ?? false,
description: json['description'] ?? '', description: json['description'] ?? '',
isSystem: json['isSystem'] ?? false,
isActive: json['isActive'] ?? false,
); );
} }
@ -70,9 +69,9 @@ class Service {
return { return {
'id': id, 'id': id,
'name': name, 'name': name,
'noOfPersonsRequired': noOfPersonsRequired,
'isAttachmentRequried': isAttachmentRequried,
'description': description, 'description': description,
'isSystem': isSystem,
'isActive': isActive,
}; };
} }
} }

View File

@ -0,0 +1,222 @@
// create_expense_bottom_sheet.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart';
import 'package:marco/helpers/utils/validators.dart';
import 'package:marco/controller/finance/payment_request_detail_controller.dart';
Future<T?> showCreateExpenseBottomSheet<T>() {
return Get.bottomSheet<T>(
_CreateExpenseBottomSheet(),
isScrollControlled: true,
);
}
class _CreateExpenseBottomSheet extends StatefulWidget {
@override
State<_CreateExpenseBottomSheet> createState() =>
_CreateExpenseBottomSheetState();
}
class _CreateExpenseBottomSheetState extends State<_CreateExpenseBottomSheet> {
final controller = Get.put(PaymentRequestDetailController());
final _formKey = GlobalKey<FormState>();
final _paymentModeDropdownKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Obx(
() => Form(
key: _formKey,
child: BaseBottomSheet(
title: "Create New Expense",
isSubmitting: controller.isSubmitting.value,
onCancel: Get.back,
onSubmit: () async {
if (_formKey.currentState!.validate() && _validateSelections()) {
final success = await controller.submitExpense();
if (success) {
Get.back();
showAppSnackbar(
title: "Success",
message: "Expense created successfully!",
type: SnackbarType.success,
);
}
}
},
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDropdown(
"Payment Mode*",
Icons.payment_outlined,
controller.selectedPaymentMode.value?.name ?? "Select Mode",
controller.paymentModes,
(p) => p.name,
controller.selectPaymentMode,
key: _paymentModeDropdownKey,
),
_gap(),
_buildTextField(
"GST Number",
Icons.receipt_outlined,
controller.gstNumberController,
hint: "Enter GST Number",
validator: null, // optional field
),
_gap(),
_buildTextField(
"Location*",
Icons.location_on_outlined,
controller.locationController,
hint: "Enter location",
validator: Validators.requiredField,
keyboardType: TextInputType.text,
suffixIcon: IconButton(
icon: const Icon(Icons.my_location_outlined),
onPressed: () async {
await controller.fetchCurrentLocation();
},
),
),
_gap(),
_buildAttachmentField(),
],
),
),
),
),
);
}
Widget _buildDropdown<T>(String title, IconData icon, String value,
List<T> options, String Function(T) getLabel, ValueChanged<T> onSelected,
{required GlobalKey key}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(icon: icon, title: title, requiredField: true),
MySpacing.height(6),
DropdownTile(
key: key,
title: value,
onTap: () => _showOptionList(options, getLabel, onSelected, key),
),
],
);
}
Widget _buildTextField(
String title,
IconData icon,
TextEditingController controller, {
String? hint,
FormFieldValidator<String>? validator,
TextInputType? keyboardType,
Widget? suffixIcon, // add this
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(
icon: icon, title: title, requiredField: validator != null),
MySpacing.height(6),
CustomTextField(
controller: controller,
hint: hint ?? "",
validator: validator,
keyboardType: keyboardType ?? TextInputType.text,
suffixIcon: suffixIcon,
),
],
);
}
Widget _buildAttachmentField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(
icon: Icons.attach_file,
title: "Upload Bill*",
requiredField: true),
MySpacing.height(6),
Obx(() {
if (controller.isProcessingAttachment.value) {
return Center(
child: Column(
children: const [
CircularProgressIndicator(),
SizedBox(height: 8),
Text("Processing file, please wait..."),
],
),
);
}
return AttachmentsSection(
attachments: controller.attachments,
existingAttachments: controller.existingAttachments,
onRemoveNew: controller.removeAttachment,
controller: controller,
onAdd: controller.pickAttachments,
);
}),
],
);
}
Widget _gap([double h = 16]) => MySpacing.height(h);
Future<void> _showOptionList<T>(List<T> options, String Function(T) getLabel,
ValueChanged<T> onSelected, GlobalKey key) async {
if (options.isEmpty) {
_showError("No options available");
return;
}
final RenderBox button =
key.currentContext!.findRenderObject() as RenderBox;
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero, ancestor: overlay);
final selected = await showMenu<T>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy + button.size.height,
overlay.size.width - position.dx - button.size.width,
0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
items: options
.map(
(opt) => PopupMenuItem<T>(value: opt, child: Text(getLabel(opt))))
.toList(),
);
if (selected != null) onSelected(selected);
}
bool _validateSelections() {
if (controller.selectedPaymentMode.value == null) {
return _showError("Please select a payment mode");
}
if (controller.locationController.text.trim().isEmpty) {
return _showError("Please enter location");
}
if (controller.attachments.isEmpty) {
return _showError("Please upload bill");
}
return true;
}
bool _showError(String msg) {
showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error);
return false;
}
}

View File

@ -0,0 +1,65 @@
class PaymentModeResponse {
final bool success;
final String message;
final List<PaymentModeData> data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
PaymentModeResponse({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory PaymentModeResponse.fromJson(Map<String, dynamic> json) {
return PaymentModeResponse(
success: json['success'] as bool,
message: json['message'] as String,
data: (json['data'] as List)
.map((item) => PaymentModeData.fromJson(item))
.toList(),
errors: json['errors'],
statusCode: json['statusCode'] as int,
timestamp: DateTime.parse(json['timestamp'] as String),
);
}
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data.map((e) => e.toJson()).toList(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
class PaymentModeData {
final String id;
final String name;
final String description;
PaymentModeData({
required this.id,
required this.name,
required this.description,
});
factory PaymentModeData.fromJson(Map<String, dynamic> json) {
return PaymentModeData(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'description': description,
};
}

View File

@ -0,0 +1,39 @@
class PaymentRequestPayeeResponse {
final bool success;
final String message;
final List<String> data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
PaymentRequestPayeeResponse({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory PaymentRequestPayeeResponse.fromJson(Map<String, dynamic> json) {
return PaymentRequestPayeeResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: List<String>.from(json['data'] ?? []),
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: DateTime.parse(json['timestamp']),
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data,
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
}

View File

@ -0,0 +1,445 @@
class PaymentRequestDetail {
bool success;
String message;
PaymentRequestData? data;
dynamic errors;
int statusCode;
DateTime timestamp;
PaymentRequestDetail({
required this.success,
required this.message,
this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory PaymentRequestDetail.fromJson(Map<String, dynamic> json) =>
PaymentRequestDetail(
success: json['success'],
message: json['message'],
data: json['data'] != null
? PaymentRequestData.fromJson(json['data'])
: null,
errors: json['errors'],
statusCode: json['statusCode'],
timestamp: DateTime.parse(json['timestamp']),
);
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
class PaymentRequestData {
String id;
String title;
String description;
String paymentRequestUID;
String payee;
Currency currency;
double amount;
double? baseAmount;
double? taxAmount;
DateTime dueDate;
Project project;
dynamic recurringPayment;
ExpenseCategory expenseCategory;
ExpenseStatus expenseStatus;
String? paidTransactionId;
DateTime? paidAt;
User? paidBy;
bool isAdvancePayment;
DateTime createdAt;
User createdBy;
DateTime updatedAt;
User? updatedBy;
List<NextStatus> nextStatus;
List<UpdateLog> updateLogs;
List<Attachment> attachments;
bool isActive;
bool isExpenseCreated;
PaymentRequestData({
required this.id,
required this.title,
required this.description,
required this.paymentRequestUID,
required this.payee,
required this.currency,
required this.amount,
this.baseAmount,
this.taxAmount,
required this.dueDate,
required this.project,
this.recurringPayment,
required this.expenseCategory,
required this.expenseStatus,
this.paidTransactionId,
this.paidAt,
this.paidBy,
required this.isAdvancePayment,
required this.createdAt,
required this.createdBy,
required this.updatedAt,
this.updatedBy,
required this.nextStatus,
required this.updateLogs,
required this.attachments,
required this.isActive,
required this.isExpenseCreated,
});
factory PaymentRequestData.fromJson(Map<String, dynamic> json) =>
PaymentRequestData(
id: json['id'],
title: json['title'],
description: json['description'],
paymentRequestUID: json['paymentRequestUID'],
payee: json['payee'],
currency: Currency.fromJson(json['currency']),
amount: (json['amount'] as num).toDouble(),
baseAmount: json['baseAmount'] != null
? (json['baseAmount'] as num).toDouble()
: null,
taxAmount: json['taxAmount'] != null
? (json['taxAmount'] as num).toDouble()
: null,
dueDate: DateTime.parse(json['dueDate']),
project: Project.fromJson(json['project']),
recurringPayment: json['recurringPayment'],
expenseCategory: ExpenseCategory.fromJson(json['expenseCategory']),
expenseStatus: ExpenseStatus.fromJson(json['expenseStatus']),
paidTransactionId: json['paidTransactionId'],
paidAt: json['paidAt'] != null ? DateTime.parse(json['paidAt']) : null,
paidBy: json['paidBy'] != null ? User.fromJson(json['paidBy']) : null,
isAdvancePayment: json['isAdvancePayment'],
createdAt: DateTime.parse(json['createdAt']),
createdBy: User.fromJson(json['createdBy']),
updatedAt: DateTime.parse(json['updatedAt']),
updatedBy:
json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null,
nextStatus: (json['nextStatus'] as List<dynamic>)
.map((e) => NextStatus.fromJson(e))
.toList(),
updateLogs: (json['updateLogs'] as List<dynamic>)
.map((e) => UpdateLog.fromJson(e))
.toList(),
attachments: (json['attachments'] as List<dynamic>)
.map((e) => Attachment.fromJson(e))
.toList(),
isActive: json['isActive'],
isExpenseCreated: json['isExpenseCreated'],
);
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'description': description,
'paymentRequestUID': paymentRequestUID,
'payee': payee,
'currency': currency.toJson(),
'amount': amount,
'baseAmount': baseAmount,
'taxAmount': taxAmount,
'dueDate': dueDate.toIso8601String(),
'project': project.toJson(),
'recurringPayment': recurringPayment,
'expenseCategory': expenseCategory.toJson(),
'expenseStatus': expenseStatus.toJson(),
'paidTransactionId': paidTransactionId,
'paidAt': paidAt?.toIso8601String(),
'paidBy': paidBy?.toJson(),
'isAdvancePayment': isAdvancePayment,
'createdAt': createdAt.toIso8601String(),
'createdBy': createdBy.toJson(),
'updatedAt': updatedAt.toIso8601String(),
'updatedBy': updatedBy?.toJson(),
'nextStatus': nextStatus.map((e) => e.toJson()).toList(),
'updateLogs': updateLogs.map((e) => e.toJson()).toList(),
'attachments': attachments.map((e) => e.toJson()).toList(),
'isActive': isActive,
'isExpenseCreated': isExpenseCreated,
};
}
class Currency {
String id;
String currencyCode;
String currencyName;
String symbol;
bool isActive;
Currency({
required this.id,
required this.currencyCode,
required this.currencyName,
required this.symbol,
required this.isActive,
});
factory Currency.fromJson(Map<String, dynamic> json) => Currency(
id: json['id'],
currencyCode: json['currencyCode'],
currencyName: json['currencyName'],
symbol: json['symbol'],
isActive: json['isActive'],
);
Map<String, dynamic> toJson() => {
'id': id,
'currencyCode': currencyCode,
'currencyName': currencyName,
'symbol': symbol,
'isActive': isActive,
};
}
class Project {
String id;
String name;
Project({required this.id, required this.name});
factory Project.fromJson(Map<String, dynamic> json) =>
Project(id: json['id'], name: json['name']);
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}
class ExpenseCategory {
String id;
String name;
bool noOfPersonsRequired;
bool isAttachmentRequried;
String description;
ExpenseCategory({
required this.id,
required this.name,
required this.noOfPersonsRequired,
required this.isAttachmentRequried,
required this.description,
});
factory ExpenseCategory.fromJson(Map<String, dynamic> json) =>
ExpenseCategory(
id: json['id'],
name: json['name'],
noOfPersonsRequired: json['noOfPersonsRequired'],
isAttachmentRequried: json['isAttachmentRequried'],
description: json['description'],
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'noOfPersonsRequired': noOfPersonsRequired,
'isAttachmentRequried': isAttachmentRequried,
'description': description,
};
}
class ExpenseStatus {
String id;
String name;
String displayName;
String description;
List<String>? permissionIds;
String color;
bool isSystem;
ExpenseStatus({
required this.id,
required this.name,
required this.displayName,
required this.description,
this.permissionIds,
required this.color,
required this.isSystem,
});
factory ExpenseStatus.fromJson(Map<String, dynamic> json) => ExpenseStatus(
id: json['id'],
name: json['name'],
displayName: json['displayName'],
description: json['description'],
permissionIds: json['permissionIds'] != null
? List<String>.from(json['permissionIds'])
: null,
color: json['color'],
isSystem: json['isSystem'],
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'displayName': displayName,
'description': description,
'permissionIds': permissionIds,
'color': color,
'isSystem': isSystem,
};
}
class User {
String id;
String firstName;
String lastName;
String email;
String photo;
String jobRoleId;
String jobRoleName;
User({
required this.id,
required this.firstName,
required this.lastName,
required this.email,
required this.photo,
required this.jobRoleId,
required this.jobRoleName,
});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'],
firstName: json['firstName'],
lastName: json['lastName'],
email: json['email'],
photo: json['photo'],
jobRoleId: json['jobRoleId'],
jobRoleName: json['jobRoleName'],
);
Map<String, dynamic> toJson() => {
'id': id,
'firstName': firstName,
'lastName': lastName,
'email': email,
'photo': photo,
'jobRoleId': jobRoleId,
'jobRoleName': jobRoleName,
};
}
class NextStatus {
String id;
String name;
String displayName;
String description;
List<String>? permissionIds;
String color;
bool isSystem;
NextStatus({
required this.id,
required this.name,
required this.displayName,
required this.description,
this.permissionIds,
required this.color,
required this.isSystem,
});
factory NextStatus.fromJson(Map<String, dynamic> json) => NextStatus(
id: json['id'],
name: json['name'],
displayName: json['displayName'],
description: json['description'],
permissionIds: json['permissionIds'] != null
? List<String>.from(json['permissionIds'])
: null,
color: json['color'],
isSystem: json['isSystem'],
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'displayName': displayName,
'description': description,
'permissionIds': permissionIds,
'color': color,
'isSystem': isSystem,
};
}
class UpdateLog {
String id;
ExpenseStatus? status;
ExpenseStatus nextStatus;
String comment;
DateTime updatedAt;
User updatedBy;
UpdateLog({
required this.id,
this.status,
required this.nextStatus,
required this.comment,
required this.updatedAt,
required this.updatedBy,
});
factory UpdateLog.fromJson(Map<String, dynamic> json) => UpdateLog(
id: json['id'],
status: json['status'] != null
? ExpenseStatus.fromJson(json['status'])
: null,
nextStatus: ExpenseStatus.fromJson(json['nextStatus']),
comment: json['comment'],
updatedAt: DateTime.parse(json['updatedAt']),
updatedBy: User.fromJson(json['updatedBy']),
);
Map<String, dynamic> toJson() => {
'id': id,
'status': status?.toJson(),
'nextStatus': nextStatus.toJson(),
'comment': comment,
'updatedAt': updatedAt.toIso8601String(),
'updatedBy': updatedBy.toJson(),
};
}
class Attachment {
String id;
String fileName;
String url;
String? thumbUrl;
int fileSize;
String contentType;
Attachment({
required this.id,
required this.fileName,
required this.url,
this.thumbUrl,
required this.fileSize,
required this.contentType,
});
factory Attachment.fromJson(Map<String, dynamic> json) => Attachment(
id: json['id'],
fileName: json['fileName'],
url: json['url'],
thumbUrl: json['thumbUrl'],
fileSize: json['fileSize'],
contentType: json['contentType'],
);
Map<String, dynamic> toJson() => {
'id': id,
'fileName': fileName,
'url': url,
'thumbUrl': thumbUrl,
'fileSize': fileSize,
'contentType': contentType,
};
}

View File

@ -0,0 +1,108 @@
import 'dart:convert';
PaymentRequestFilter paymentRequestFilterFromJson(String str) =>
PaymentRequestFilter.fromJson(json.decode(str));
String paymentRequestFilterToJson(PaymentRequestFilter data) =>
json.encode(data.toJson());
class PaymentRequestFilter {
PaymentRequestFilter({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
bool success;
String message;
PaymentRequestFilterData data;
dynamic errors;
int statusCode;
DateTime timestamp;
factory PaymentRequestFilter.fromJson(Map<String, dynamic> json) =>
PaymentRequestFilter(
success: json["success"],
message: json["message"],
data: PaymentRequestFilterData.fromJson(json["data"]),
errors: json["errors"],
statusCode: json["statusCode"],
timestamp: DateTime.parse(json["timestamp"]),
);
Map<String, dynamic> toJson() => {
"success": success,
"message": message,
"data": data.toJson(),
"errors": errors,
"statusCode": statusCode,
"timestamp": timestamp.toIso8601String(),
};
}
class PaymentRequestFilterData {
PaymentRequestFilterData({
required this.projects,
required this.currency,
required this.createdBy,
required this.status,
required this.expenseCategory,
required this.payees,
});
List<IdNameModel> projects;
List<IdNameModel> currency;
List<IdNameModel> createdBy;
List<IdNameModel> status;
List<IdNameModel> expenseCategory;
List<IdNameModel> payees;
factory PaymentRequestFilterData.fromJson(Map<String, dynamic> json) =>
PaymentRequestFilterData(
projects: List<IdNameModel>.from(
json["projects"].map((x) => IdNameModel.fromJson(x))),
currency: List<IdNameModel>.from(
json["currency"].map((x) => IdNameModel.fromJson(x))),
createdBy: List<IdNameModel>.from(
json["createdBy"].map((x) => IdNameModel.fromJson(x))),
status: List<IdNameModel>.from(
json["status"].map((x) => IdNameModel.fromJson(x))),
expenseCategory: List<IdNameModel>.from(
json["expenseCategory"].map((x) => IdNameModel.fromJson(x))),
payees: List<IdNameModel>.from(
json["payees"].map((x) => IdNameModel.fromJson(x))),
);
Map<String, dynamic> toJson() => {
"projects": List<dynamic>.from(projects.map((x) => x.toJson())),
"currency": List<dynamic>.from(currency.map((x) => x.toJson())),
"createdBy": List<dynamic>.from(createdBy.map((x) => x.toJson())),
"status": List<dynamic>.from(status.map((x) => x.toJson())),
"expenseCategory":
List<dynamic>.from(expenseCategory.map((x) => x.toJson())),
"payees": List<dynamic>.from(payees.map((x) => x.toJson())),
};
}
class IdNameModel {
IdNameModel({
required this.id,
required this.name,
});
String id;
String name;
factory IdNameModel.fromJson(Map<String, dynamic> json) => IdNameModel(
id: json["id"].toString(),
name: json["name"] ?? "",
);
Map<String, dynamic> toJson() => {
"id": id,
"name": name,
};
}

View File

@ -0,0 +1,471 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/finance/payment_request_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/date_range_picker.dart';
import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/model/expense/employee_selector_for_filter_bottom_sheet.dart';
class PaymentRequestFilterBottomSheet extends StatefulWidget {
final PaymentRequestController controller;
final ScrollController scrollController;
const PaymentRequestFilterBottomSheet({
super.key,
required this.controller,
required this.scrollController,
});
@override
State<PaymentRequestFilterBottomSheet> createState() =>
_PaymentRequestFilterBottomSheetState();
}
class _PaymentRequestFilterBottomSheetState
extends State<PaymentRequestFilterBottomSheet> with UIMixin {
// ---------------- Date Range ----------------
final Rx<DateTime?> startDate = Rx<DateTime?>(null);
final Rx<DateTime?> endDate = Rx<DateTime?>(null);
// ---------------- Selected Filters (store IDs internally) ----------------
final RxString selectedProjectId = ''.obs;
final RxList<EmployeeModel> selectedSubmittedBy = <EmployeeModel>[].obs;
final RxList<EmployeeModel> selectedPayees = <EmployeeModel>[].obs;
final RxString selectedCategoryId = ''.obs;
final RxString selectedCurrencyId = ''.obs;
final RxString selectedStatusId = ''.obs;
// Computed display names
String get selectedProjectName =>
widget.controller.projects
.firstWhereOrNull((e) => e.id == selectedProjectId.value)
?.name ??
'Please select...';
String get selectedCategoryName =>
widget.controller.categories
.firstWhereOrNull((e) => e.id == selectedCategoryId.value)
?.name ??
'Please select...';
String get selectedCurrencyName =>
widget.controller.currencies
.firstWhereOrNull((e) => e.id == selectedCurrencyId.value)
?.name ??
'Please select...';
String get selectedStatusName =>
widget.controller.statuses
.firstWhereOrNull((e) => e.id == selectedStatusId.value)
?.name ??
'Please select...';
// ---------------- Filter Data ----------------
final RxBool isFilterLoading = true.obs;
// Individual RxLists for safe Obx usage
final RxList<String> projectNames = <String>[].obs;
final RxList<String> submittedByNames = <String>[].obs;
final RxList<String> payeeNames = <String>[].obs;
final RxList<String> categoryNames = <String>[].obs;
final RxList<String> currencyNames = <String>[].obs;
final RxList<String> statusNames = <String>[].obs;
@override
void initState() {
super.initState();
_loadFilterData();
}
Future<void> _loadFilterData() async {
isFilterLoading.value = true;
await widget.controller.fetchPaymentRequestFilterOptions();
projectNames.assignAll(widget.controller.projects.map((e) => e.name));
submittedByNames.assignAll(widget.controller.createdBy.map((e) => e.name));
payeeNames.assignAll(widget.controller.payees.map((e) => e.name));
categoryNames.assignAll(widget.controller.categories.map((e) => e.name));
currencyNames.assignAll(widget.controller.currencies.map((e) => e.name));
statusNames.assignAll(widget.controller.statuses.map((e) => e.name));
// 🔹 Prefill existing applied filter (if any)
final existing = widget.controller.appliedFilter;
if (existing.isNotEmpty) {
// Project
if (existing['projectIds'] != null &&
(existing['projectIds'] as List).isNotEmpty) {
selectedProjectId.value = (existing['projectIds'] as List).first;
}
// Submitted By
if (existing['createdByIds'] != null &&
existing['createdByIds'] is List) {
selectedSubmittedBy.assignAll(
(existing['createdByIds'] as List)
.map((id) => widget.controller.createdBy
.firstWhereOrNull((e) => e.id == id))
.whereType<EmployeeModel>()
.toList(),
);
}
// Payees
if (existing['payees'] != null && existing['payees'] is List) {
selectedPayees.assignAll(
(existing['payees'] as List)
.map((id) =>
widget.controller.payees.firstWhereOrNull((e) => e.id == id))
.whereType<EmployeeModel>()
.toList(),
);
}
// Category
if (existing['expenseCategoryIds'] != null &&
(existing['expenseCategoryIds'] as List).isNotEmpty) {
selectedCategoryId.value =
(existing['expenseCategoryIds'] as List).first;
}
// Currency
if (existing['currencyIds'] != null &&
(existing['currencyIds'] as List).isNotEmpty) {
selectedCurrencyId.value = (existing['currencyIds'] as List).first;
}
// Status
if (existing['statusIds'] != null &&
(existing['statusIds'] as List).isNotEmpty) {
selectedStatusId.value = (existing['statusIds'] as List).first;
}
// Dates
if (existing['startDate'] != null && existing['endDate'] != null) {
startDate.value = DateTime.tryParse(existing['startDate']);
endDate.value = DateTime.tryParse(existing['endDate']);
}
}
isFilterLoading.value = false;
}
Future<List<EmployeeModel>> searchEmployees(
String query, List<String> items) async {
final allEmployees = items
.map((e) => EmployeeModel(
id: e,
name: e,
firstName: e,
lastName: '',
jobRoleID: '',
employeeId: e,
designation: '',
activity: 0,
action: 0,
jobRole: '',
email: '-',
phoneNumber: '-',
))
.toList();
if (query.trim().isEmpty) return allEmployees;
return allEmployees
.where((e) => e.name.toLowerCase().contains(query.toLowerCase()))
.toList();
}
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
title: 'Filter Payment Requests',
onCancel: () => Get.back(),
onSubmit: () {
_applyFilters();
Get.back();
},
submitText: 'Apply',
submitColor: contentTheme.primary,
submitIcon: Icons.check_circle_outline,
child: SingleChildScrollView(
controller: widget.scrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: clearFilters,
child: MyText(
"Reset Filters",
style: MyTextStyle.labelMedium(
color: Colors.red,
fontWeight: 600,
),
),
),
),
MySpacing.height(8),
_buildDateRangeFilter(),
MySpacing.height(16),
_buildProjectFilter(),
MySpacing.height(16),
_buildSubmittedByFilter(),
MySpacing.height(16),
_buildPayeeFilter(),
MySpacing.height(16),
_buildCategoryFilter(),
MySpacing.height(16),
_buildCurrencyFilter(),
MySpacing.height(16),
_buildStatusFilter(),
],
),
),
);
}
void clearFilters() {
startDate.value = null;
endDate.value = null;
selectedProjectId.value = '';
selectedSubmittedBy.clear();
selectedPayees.clear();
selectedCategoryId.value = '';
selectedCurrencyId.value = '';
selectedStatusId.value = '';
widget.controller.setFilterApplied(false);
}
void _applyFilters() {
final Map<String, dynamic> filter = {
"projectIds":
selectedProjectId.value.isEmpty ? [] : [selectedProjectId.value],
"createdByIds": selectedSubmittedBy.map((e) => e.id).toList(),
"payees": selectedPayees.map((e) => e.id).toList(),
"expenseCategoryIds":
selectedCategoryId.value.isEmpty ? [] : [selectedCategoryId.value],
"currencyIds":
selectedCurrencyId.value.isEmpty ? [] : [selectedCurrencyId.value],
"statusIds":
selectedStatusId.value.isEmpty ? [] : [selectedStatusId.value],
"startDate": startDate.value?.toIso8601String(),
"endDate": endDate.value?.toIso8601String(),
};
widget.controller.applyFilter(filter);
}
Widget _buildField(String label, Widget child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
MySpacing.height(8),
child,
],
);
}
Widget _buildDateRangeFilter() {
return _buildField(
"Filter By Date",
DateRangePickerWidget(
startDate: startDate,
endDate: endDate,
startLabel: "Start Date",
endLabel: "End Date",
onDateRangeSelected: (start, end) {
startDate.value = start;
endDate.value = end;
},
),
);
}
Widget _buildProjectFilter() {
return _buildField(
"Project",
Obx(() {
if (isFilterLoading.value) return const CircularProgressIndicator();
return _popupSelector(
currentValue: selectedProjectName,
items: projectNames,
onSelected: (value) {
final proj = widget.controller.projects
.firstWhereOrNull((e) => e.name == value);
if (proj != null) selectedProjectId.value = proj.id;
},
);
}),
);
}
Widget _buildSubmittedByFilter() {
return _buildField(
"Submitted By",
Obx(() {
if (isFilterLoading.value) return const CircularProgressIndicator();
return _employeeSelector(
selectedSubmittedBy, "Search Submitted By", submittedByNames);
}),
);
}
Widget _buildPayeeFilter() {
return _buildField(
"Payee",
Obx(() {
if (isFilterLoading.value) return const CircularProgressIndicator();
return _employeeSelector(selectedPayees, "Search Payee", payeeNames);
}),
);
}
Widget _buildCategoryFilter() {
return _buildField(
"Category",
Obx(() {
if (isFilterLoading.value) return const CircularProgressIndicator();
return _popupSelector(
currentValue: selectedCategoryName,
items: categoryNames,
onSelected: (value) {
final cat = widget.controller.categories
.firstWhereOrNull((e) => e.name == value);
if (cat != null) selectedCategoryId.value = cat.id;
},
);
}),
);
}
Widget _buildCurrencyFilter() {
return _buildField(
"Currency",
Obx(() {
if (isFilterLoading.value) return const CircularProgressIndicator();
return _popupSelector(
currentValue: selectedCurrencyName,
items: currencyNames,
onSelected: (value) {
final cur = widget.controller.currencies
.firstWhereOrNull((e) => e.name == value);
if (cur != null) selectedCurrencyId.value = cur.id;
},
);
}),
);
}
Widget _buildStatusFilter() {
return _buildField(
"Status",
Obx(() {
if (isFilterLoading.value) return const CircularProgressIndicator();
return _popupSelector(
currentValue: selectedStatusName,
items: statusNames,
onSelected: (value) {
final st = widget.controller.statuses
.firstWhereOrNull((e) => e.name == value);
if (st != null) selectedStatusId.value = st.id;
},
);
}),
);
}
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(value: e, child: MyText(e))).toList(),
child: Container(
padding: MySpacing.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: MyText(
currentValue,
style: const TextStyle(color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
);
}
Widget _employeeSelector(RxList<EmployeeModel> selectedEmployees,
String title, List<String> items) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() {
if (selectedEmployees.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: 8,
children: selectedEmployees
.map((emp) => Chip(
label: MyText(emp.name),
onDeleted: () => selectedEmployees.remove(emp),
))
.toList(),
);
}),
MySpacing.height(8),
GestureDetector(
onTap: () async {
final result = await showModalBottomSheet<List<EmployeeModel>>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => EmployeeSelectorBottomSheet(
selectedEmployees: selectedEmployees,
searchEmployees: (query) => searchEmployees(query, items),
title: title,
),
);
if (result != null) selectedEmployees.assignAll(result);
},
child: Container(
padding: MySpacing.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
children: [
const Icon(Icons.search, color: Colors.grey),
MySpacing.width(8),
Expanded(child: MyText(title)),
],
),
),
),
],
);
}
}

View File

@ -0,0 +1,306 @@
import 'dart:convert';
PaymentRequestResponse paymentRequestResponseFromJson(String str) =>
PaymentRequestResponse.fromJson(json.decode(str));
String paymentRequestResponseToJson(PaymentRequestResponse data) =>
json.encode(data.toJson());
class PaymentRequestResponse {
PaymentRequestResponse({
required this.success,
required this.message,
required this.data,
});
bool success;
String message;
PaymentRequestData data;
factory PaymentRequestResponse.fromJson(Map<String, dynamic> json) =>
PaymentRequestResponse(
success: json["success"],
message: json["message"],
data: PaymentRequestData.fromJson(json["data"]),
);
Map<String, dynamic> toJson() => {
"success": success,
"message": message,
"data": data.toJson(),
};
}
class PaymentRequestData {
PaymentRequestData({
required this.currentPage,
required this.totalPages,
required this.totalEntities,
required this.data,
});
int currentPage;
int totalPages;
int totalEntities;
List<PaymentRequest> data;
factory PaymentRequestData.fromJson(Map<String, dynamic> json) =>
PaymentRequestData(
currentPage: json["currentPage"],
totalPages: json["totalPages"],
totalEntities: json["totalEntities"],
data: List<PaymentRequest>.from(
json["data"].map((x) => PaymentRequest.fromJson(x))),
);
Map<String, dynamic> toJson() => {
"currentPage": currentPage,
"totalPages": totalPages,
"totalEntities": totalEntities,
"data": List<dynamic>.from(data.map((x) => x.toJson())),
};
}
class PaymentRequest {
PaymentRequest({
required this.id,
required this.title,
required this.description,
this.recurringPayment,
required this.paymentRequestUID,
required this.payee,
required this.currency,
required this.amount,
required this.dueDate,
required this.project,
required this.expenseCategory,
required this.expenseStatus,
required this.isAdvancePayment,
required this.createdAt,
required this.createdBy,
required this.isActive,
required this.isExpenseCreated,
});
String id;
String title;
String description;
dynamic recurringPayment;
String paymentRequestUID;
String payee;
Currency currency;
num amount;
DateTime dueDate;
Project project;
ExpenseCategory expenseCategory;
ExpenseStatus expenseStatus;
bool isAdvancePayment;
DateTime createdAt;
CreatedBy createdBy;
bool isActive;
bool isExpenseCreated;
factory PaymentRequest.fromJson(Map<String, dynamic> json) => PaymentRequest(
id: json["id"],
title: json["title"],
description: json["description"],
recurringPayment: json["recurringPayment"],
paymentRequestUID: json["paymentRequestUID"],
payee: json["payee"],
currency: Currency.fromJson(json["currency"]),
amount: json["amount"],
dueDate: DateTime.parse(json["dueDate"]),
project: Project.fromJson(json["project"]),
expenseCategory: ExpenseCategory.fromJson(json["expenseCategory"]),
expenseStatus: ExpenseStatus.fromJson(json["expenseStatus"]),
isAdvancePayment: json["isAdvancePayment"],
createdAt: DateTime.parse(json["createdAt"]),
createdBy: CreatedBy.fromJson(json["createdBy"]),
isActive: json["isActive"],
isExpenseCreated: json["isExpenseCreated"],
);
Map<String, dynamic> toJson() => {
"id": id,
"title": title,
"description": description,
"recurringPayment": recurringPayment,
"paymentRequestUID": paymentRequestUID,
"payee": payee,
"currency": currency.toJson(),
"amount": amount,
"dueDate": dueDate.toIso8601String(),
"project": project.toJson(),
"expenseCategory": expenseCategory.toJson(),
"expenseStatus": expenseStatus.toJson(),
"isAdvancePayment": isAdvancePayment,
"createdAt": createdAt.toIso8601String(),
"createdBy": createdBy.toJson(),
"isActive": isActive,
"isExpenseCreated": isExpenseCreated,
};
}
class Currency {
Currency({
required this.id,
required this.currencyCode,
required this.currencyName,
required this.symbol,
required this.isActive,
});
String id;
String currencyCode;
String currencyName;
String symbol;
bool isActive;
factory Currency.fromJson(Map<String, dynamic> json) => Currency(
id: json["id"],
currencyCode: json["currencyCode"],
currencyName: json["currencyName"],
symbol: json["symbol"],
isActive: json["isActive"],
);
Map<String, dynamic> toJson() => {
"id": id,
"currencyCode": currencyCode,
"currencyName": currencyName,
"symbol": symbol,
"isActive": isActive,
};
}
class Project {
Project({
required this.id,
required this.name,
});
String id;
String name;
factory Project.fromJson(Map<String, dynamic> json) => Project(
id: json["id"],
name: json["name"],
);
Map<String, dynamic> toJson() => {
"id": id,
"name": name,
};
}
class ExpenseCategory {
ExpenseCategory({
required this.id,
required this.name,
required this.noOfPersonsRequired,
required this.isAttachmentRequried,
required this.description,
});
String id;
String name;
bool noOfPersonsRequired;
bool isAttachmentRequried;
String description;
factory ExpenseCategory.fromJson(Map<String, dynamic> json) => ExpenseCategory(
id: json["id"],
name: json["name"],
noOfPersonsRequired: json["noOfPersonsRequired"],
isAttachmentRequried: json["isAttachmentRequried"],
description: json["description"],
);
Map<String, dynamic> toJson() => {
"id": id,
"name": name,
"noOfPersonsRequired": noOfPersonsRequired,
"isAttachmentRequried": isAttachmentRequried,
"description": description,
};
}
class ExpenseStatus {
ExpenseStatus({
required this.id,
required this.name,
required this.displayName,
required this.description,
this.permissionIds,
required this.color,
required this.isSystem,
});
String id;
String name;
String displayName;
String description;
dynamic permissionIds;
String color;
bool isSystem;
factory ExpenseStatus.fromJson(Map<String, dynamic> json) => ExpenseStatus(
id: json["id"],
name: json["name"],
displayName: json["displayName"],
description: json["description"],
permissionIds: json["permissionIds"],
color: json["color"],
isSystem: json["isSystem"],
);
Map<String, dynamic> toJson() => {
"id": id,
"name": name,
"displayName": displayName,
"description": description,
"permissionIds": permissionIds,
"color": color,
"isSystem": isSystem,
};
}
class CreatedBy {
CreatedBy({
required this.id,
required this.firstName,
required this.lastName,
required this.email,
required this.photo,
required this.jobRoleId,
required this.jobRoleName,
});
String id;
String firstName;
String lastName;
String email;
String photo;
String jobRoleId;
String jobRoleName;
factory CreatedBy.fromJson(Map<String, dynamic> json) => CreatedBy(
id: json["id"],
firstName: json["firstName"],
lastName: json["lastName"],
email: json["email"],
photo: json["photo"],
jobRoleId: json["jobRoleId"],
jobRoleName: json["jobRoleName"],
);
Map<String, dynamic> toJson() => {
"id": id,
"firstName": firstName,
"lastName": lastName,
"email": email,
"photo": photo,
"jobRoleId": jobRoleId,
"jobRoleName": jobRoleName,
};
}

View File

@ -0,0 +1,271 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/finance/payment_request_detail_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/expense/employee_selector_bottom_sheet.dart';
class UpdatePaymentRequestWithReimbursement extends StatefulWidget {
final String expenseId;
final String statusId;
final void Function() onClose;
const UpdatePaymentRequestWithReimbursement({
super.key,
required this.expenseId,
required this.onClose,
required this.statusId,
});
@override
State<UpdatePaymentRequestWithReimbursement> createState() =>
_UpdatePaymentRequestWithReimbursement();
}
class _UpdatePaymentRequestWithReimbursement
extends State<UpdatePaymentRequestWithReimbursement> {
final PaymentRequestDetailController controller =
Get.find<PaymentRequestDetailController>();
final TextEditingController commentCtrl = TextEditingController();
final TextEditingController txnCtrl = TextEditingController();
final TextEditingController tdsCtrl = TextEditingController(text: '0');
final TextEditingController baseAmountCtrl = TextEditingController();
final TextEditingController taxAmountCtrl = TextEditingController();
final RxString dateStr = ''.obs;
@override
void dispose() {
commentCtrl.dispose();
txnCtrl.dispose();
tdsCtrl.dispose();
baseAmountCtrl.dispose();
taxAmountCtrl.dispose();
super.dispose();
}
/// Employee selection bottom sheet
void _showEmployeeList() async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
backgroundColor: Colors.transparent,
builder: (_) => ReusableEmployeeSelectorBottomSheet(
searchController: controller.employeeSearchController,
searchResults: controller.employeeSearchResults,
isSearching: controller.isSearchingEmployees,
onSearch: controller.searchEmployees,
onSelect: (emp) => controller.selectedReimbursedBy.value = emp,
),
);
// Optional cleanup
controller.employeeSearchController.clear();
controller.employeeSearchResults.clear();
}
InputDecoration _inputDecoration(String hint) {
return InputDecoration(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
),
contentPadding: MySpacing.all(16),
);
}
@override
Widget build(BuildContext context) {
return Obx(() {
return BaseBottomSheet(
title: "Proceed Payment",
isSubmitting: controller.isLoading.value,
onCancel: () {
widget.onClose();
Navigator.pop(context);
},
onSubmit: () async {
// Mandatory fields validation
if (commentCtrl.text.trim().isEmpty ||
txnCtrl.text.trim().isEmpty ||
dateStr.value.isEmpty ||
baseAmountCtrl.text.trim().isEmpty ||
taxAmountCtrl.text.trim().isEmpty) {
showAppSnackbar(
title: "Incomplete",
message: "Please fill all mandatory fields",
type: SnackbarType.warning,
);
return;
}
try {
// Parse inputs
final parsedDate =
DateFormat('dd-MM-yyyy').parse(dateStr.value, true);
final baseAmount = double.tryParse(baseAmountCtrl.text.trim()) ?? 0;
final taxAmount = double.tryParse(taxAmountCtrl.text.trim()) ?? 0;
final tdsPercentage =
tdsCtrl.text.trim().isEmpty ? null : tdsCtrl.text.trim();
// Call API
final success = await controller.updatePaymentRequestStatus(
statusId: widget.statusId,
comment: commentCtrl.text.trim(),
paidTransactionId: txnCtrl.text.trim(),
paidById: controller.selectedReimbursedBy.value?.id,
paidAt: parsedDate,
baseAmount: baseAmount,
taxAmount: taxAmount,
tdsPercentage: tdsPercentage,
);
// Show snackbar
showAppSnackbar(
title: success ? 'Success' : 'Error',
message: success
? 'Payment updated successfully'
: 'Failed to update payment',
type: success ? SnackbarType.success : SnackbarType.error,
);
if (success) {
// Ensure bottom sheet closes and callback is called
widget.onClose(); // optional callback for parent refresh
if (Navigator.canPop(context)) {
Navigator.pop(context);
} else {
Get.close(1); // fallback if Navigator can't pop
}
}
} catch (e, st) {
print("Error updating payment: $e\n$st");
showAppSnackbar(
title: 'Error',
message: 'Something went wrong. Please try again.',
type: SnackbarType.error,
);
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium("Transaction ID*"),
MySpacing.height(8),
TextField(
controller: txnCtrl,
decoration: _inputDecoration("Enter transaction ID"),
),
MySpacing.height(16),
MyText.labelMedium("Transaction Date*"),
MySpacing.height(8),
GestureDetector(
onTap: () async {
final today = DateTime.now();
final firstDate = DateTime(2020);
final lastDate = today;
final picked = await showDatePicker(
context: context,
initialDate: today,
firstDate: firstDate,
lastDate: lastDate,
);
if (picked != null) {
dateStr.value = DateFormat('dd-MM-yyyy').format(picked);
}
},
child: AbsorbPointer(
child: TextField(
controller: TextEditingController(text: dateStr.value),
decoration: _inputDecoration("Select Date").copyWith(
suffixIcon: const Icon(Icons.calendar_today),
),
),
),
),
MySpacing.height(16),
MyText.labelMedium("Paid By (Optional)"),
MySpacing.height(8),
GestureDetector(
onTap: _showEmployeeList,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
controller.selectedReimbursedBy.value == null
? "Select Paid By"
: '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}',
style: const TextStyle(fontSize: 14),
),
const Icon(Icons.arrow_drop_down, size: 22),
],
),
),
),
MySpacing.height(16),
MyText.labelMedium("TDS Percentage (Optional)"),
MySpacing.height(8),
TextField(
controller: tdsCtrl,
keyboardType: TextInputType.number,
decoration: _inputDecoration("Enter TDS Percentage"),
),
MySpacing.height(16),
MyText.labelMedium("Base Amount*"),
MySpacing.height(8),
TextField(
controller: baseAmountCtrl,
keyboardType: TextInputType.number,
decoration: _inputDecoration("Enter Base Amount"),
),
MySpacing.height(16),
MyText.labelMedium("Tax Amount*"),
MySpacing.height(8),
TextField(
controller: taxAmountCtrl,
keyboardType: TextInputType.number,
decoration: _inputDecoration("Enter Tax Amount"),
),
MySpacing.height(16),
MyText.labelMedium("Comment*"),
MySpacing.height(8),
TextField(
controller: commentCtrl,
decoration: _inputDecoration("Enter comment"),
),
],
),
);
});
}
}

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