Compare commits

...

69 Commits

Author SHA1 Message Date
83218166ba revert 7fb5a5217aee404e3c940f0729086460d9bd1c4c
revert fixed initial loging
2025-10-11 08:49:37 +00:00
26611d3650 reduced snaxkbar time 2025-10-11 14:16:38 +05:30
7fb5a5217a fixed initial loging 2025-10-11 11:42:40 +05:30
706881d08d Merge pull request 'Issue_10_10_2025' (#75) from Issue_10_10_2025 into main
Reviewed-on: #75
2025-10-11 04:57:46 +00:00
e8acfe10d9 enhanced the restore button logic 2025-10-10 20:01:16 +05:30
16e2f5a4f3 enhanced desabled cards 2025-10-10 19:52:12 +05:30
cd92d4d309 added multiple bucket assignment 2025-10-10 19:46:17 +05:30
5dc2db0a8b fixed the issues 2025-10-10 19:41:13 +05:30
bb5fdb27b2 fixed delete and ad comment not updating properly in contact detailsnotes 2025-10-10 10:10:46 +05:30
acb203848e fixed the bugs 2025-10-09 18:09:54 +05:30
e6238ca5b0 Merge pull request 'Tenant_Selection_Issue_Fixed' (#74) from Tenant_Selection_Issue_Fixed into main
Reviewed-on: #74
2025-10-09 09:28:43 +00:00
d5a8d08e63 added splash screen 2025-10-08 17:33:20 +05:30
041b62ca2f fixed the tennt selection process 2025-10-08 16:07:26 +05:30
d1d48b1a74 fixed auto tenant selection 2025-10-08 11:41:38 +05:30
7e75431feb fixed permissions loading issue on employee screen 2025-10-08 11:20:50 +05:30
45bc492683 fixed tenant issue 2025-10-08 11:06:39 +05:30
26675388dd corrected the prefield data while editing employee 2025-10-06 12:27:43 +05:30
d02211d389 feat: Add Flutter APK build script with dependency management and output paths 2025-09-30 14:42:06 +05:30
5086b3be98 Merge pull request 'Vaibhav_Enhancement-#1129' (#73) from Vaibhav_Enhancement-#1129 into main
Reviewed-on: #73
2025-09-30 09:09:35 +00:00
539e94fc99 feat: Rename "Comment" to "Note" in relevant UI components and dialogs 2025-09-30 13:25:31 +05:30
dbd4a42b7a feat: Improve comment action buttons layout and spacing in ContactDetailScreen 2025-09-30 11:56:32 +05:30
38ae9e3571 feat: Update GlobalProjectModel and ProjectModel to handle nullable date fields and improve JSON parsing 2025-09-30 11:03:33 +05:30
7f924ee533 feat: Add restore and delete functionality for comments with confirmation dialogs 2025-09-29 17:51:13 +05:30
d6587931fa feat: Implement restore and delete functionality for notes with confirmation dialog 2025-09-29 17:03:47 +05:30
8ad9690d89 feat: Allow multi-line input for description field in AddContactBottomSheet 2025-09-29 16:04:10 +05:30
b286ab854a feat: Add designation field to contact model and update add contact functionality 2025-09-29 16:03:03 +05:30
8576448a32 feat: Enhance AddEmployee functionality with email and organization selection support 2025-09-27 16:56:56 +05:30
075167e285 feat: Update Dashboard Overview to display total employees instead of absent count 2025-09-27 11:30:18 +05:30
fd7c338c05 refactor: Remove loading skeletons from attendance and project progress charts for improved performance 2025-09-25 18:35:39 +05:30
b5d8d41e42 feat: Redesign Dashboard Overview Widgets with enhanced metrics display and loading states 2025-09-25 17:04:22 +05:30
781a8dabaf feat: Implement recent tenant selection logic and enhance tenant loading process 2025-09-25 15:06:57 +05:30
98612db7b5 feat: Enhance AttendanceLogViewButton with state management and improved log display 2025-09-25 14:42:22 +05:30
7c21324b42 refactor: Remove unused tenant loading logic and streamline tenant selection process 2025-09-25 11:35:27 +05:30
1900e944e5 feat: Implement tenant selection logic and load saved tenant from local storage 2025-09-25 11:27:03 +05:30
53fefbba50 refactor: Update border radius for UI consistency in DocumentDetailsPage 2025-09-24 17:48:12 +05:30
9012218a44 refactor: Adjust border radius in EmployeeDetailPage and update padding in EmployeesScreen 2025-09-24 17:42:42 +05:30
fb26ba0757 feat: Add maxLines property to LabeledInput and update document tile to conditionally show date header 2025-09-24 17:38:24 +05:30
aa5ae29284 feat: Update label for visibility toggle to 'Show Deleted Contacts' 2025-09-24 17:22:09 +05:30
83d9d0689a feat: Implement tabbed navigation and enhance UI for expense and directory views 2025-09-24 17:16:38 +05:30
85d3dedbef feat: Enhance organization selection and fetching logic with reactive state management 2025-09-24 15:37:06 +05:30
1d9c416f68 refactor: Standardize border radius values across dashboard components 2025-09-24 15:04:51 +05:30
7d211e24f8 feat: Implement pagination and service filtering in daily task fetching logic 2025-09-23 15:02:37 +05:30
fc081c779e feat: Add submit button text to Attendance Filter bottom sheet 2025-09-22 17:44:02 +05:30
4cb60138c0 refactor: Simplify color assignment logic in AttendanceDashboardChart and ProjectProgressChart 2025-09-22 17:15:57 +05:30
68cfdf54d6 feat: Add service selection functionality and integrate with task fetching logic 2025-09-22 16:49:36 +05:30
83a8abbb87 feat: Implement organization selection functionality and integrate with employee fetching logic 2025-09-22 15:54:32 +05:30
17c7b9f10d feat: Update Tenant model to use Industry and TenantStatus objects and improve industry display in TenantCard 2025-09-22 14:39:48 +05:30
efb5564fcb feat: Add project refresh logic upon tenant selection to ensure updated project data 2025-09-22 14:27:25 +05:30
637426aea4 feat: Enhance organization selector to include 'All Organizations' option and improve selection logic 2025-09-22 12:14:48 +05:30
8ed67dcdf1 feat: Increase default timeout duration for API requests to enhance reliability 2025-09-22 11:21:23 +05:30
6863769b8a feat: Add organization selection and related API integration in attendance module 2025-09-21 18:17:00 +05:30
8d3c900262 feat: Implement tenant selection feature with UI and service integration 2025-09-21 16:39:22 +05:30
9362945d60 Merge pull request 'Vaibhav_Enhancement-#1253' (#72) from Vaibhav_Enhancement-#1253 into main
Reviewed-on: #72
2025-09-21 04:33:42 +00:00
b5e9c7b6a3 feat: Update base URL to stage API for development environment 2025-09-20 16:42:40 +05:30
04cbdab277 feat: Enhance daily task planning by fetching infra details and associated tasks; update API service for new endpoints 2025-09-20 16:32:11 +05:30
ae7ce851ee feat: Update User Document Filter to use radio buttons for document status selection 2025-09-19 17:49:27 +05:30
8c99ba287f Merge pull request 'feat: Add InvoiceLogs widget to display expense logs in the detail screen' (#71) from Vaibhav_Task-#1177 into main
Reviewed-on: #71
2025-09-19 06:54:28 +00:00
bbe7f4a215 feat: Add InvoiceLogs widget to display expense logs in the detail screen 2025-09-19 06:54:28 +00:00
85d776b60b Merge pull request 'Vaibhav_Task-#1177' (#70) from Vaibhav_Task-#1177 into main
Reviewed-on: #70
2025-09-19 04:39:58 +00:00
4836dd994c feat: Implement employee editing functionality; add prefill logic and update API service for createOrUpdateEmployee 2025-09-18 17:42:27 +05:30
544eb4dc79 feat: Add todaysAssigned field to WorkItem model and implement JSON parsing 2025-09-18 17:15:23 +05:30
957bae526f feat: Increase icon size in directory view for better visibility 2025-09-18 16:36:05 +05:30
a1cd212e74 style: Improve code formatting; enhance readability by adjusting line breaks and widget dimensions 2025-09-18 16:32:49 +05:30
47666c7897 feat: Enhance contact detail screen; implement reactive contact updates and improve note handling 2025-09-18 16:18:01 +05:30
e6f028d129 feat: Improve notification handling; enhance logging and ensure DocumentController registration before updates 2025-09-18 15:16:53 +05:30
1fafe77211 feat: Enhance document filtering; implement multi-select support and add date range filters 2025-09-18 12:30:44 +05:30
25b20fedda feat: Enhance dashboard refresh logic; add handling for various notification types and improve method organization 2025-09-18 11:01:03 +05:30
7d5d2b5bf4 feat: Refactor document upload UI; reposition and revalidate Document ID and Name fields 2025-09-17 17:17:57 +05:30
ef1403bec9 Merge pull request 'feat: Re-enable Firebase Messaging and FCM token handling; improve local storage initialization and logging' (#69) from Vaibhav_Task-#961 into main
Reviewed-on: #69
2025-09-17 06:17:56 +00:00
93 changed files with 8374 additions and 3395 deletions

55
build_release.sh Normal file
View File

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

View File

@ -15,7 +15,7 @@ import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/model/attendance/attendance_log_model.dart'; import 'package:marco/model/attendance/attendance_log_model.dart';
import 'package:marco/model/regularization_log_model.dart'; import 'package:marco/model/regularization_log_model.dart';
import 'package:marco/model/attendance/attendance_log_view_model.dart'; import 'package:marco/model/attendance/attendance_log_view_model.dart';
import 'package:marco/model/attendance/organization_per_project_list_model.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
class AttendanceController extends GetxController { class AttendanceController extends GetxController {
@ -26,9 +26,13 @@ class AttendanceController extends GetxController {
List<AttendanceLogModel> attendanceLogs = []; List<AttendanceLogModel> attendanceLogs = [];
List<RegularizationLogModel> regularizationLogs = []; List<RegularizationLogModel> regularizationLogs = [];
List<AttendanceLogViewModel> attendenceLogsView = []; List<AttendanceLogViewModel> attendenceLogsView = [];
// ------------------ Organizations ------------------
List<Organization> organizations = [];
Organization? selectedOrganization;
final isLoadingOrganizations = false.obs;
// States // States
String selectedTab = 'Employee List'; String selectedTab = 'todaysAttendance';
DateTime? startDateAttendance; DateTime? startDateAttendance;
DateTime? endDateAttendance; DateTime? endDateAttendance;
@ -45,11 +49,16 @@ class AttendanceController extends GetxController {
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() {
_setDefaultDateRange(); _setDefaultDateRange();
fetchProjects();
} }
void _setDefaultDateRange() { void _setDefaultDateRange() {
@ -104,29 +113,15 @@ class AttendanceController extends GetxController {
.toList(); .toList();
} }
Future<void> fetchProjects() async { Future<void> fetchTodaysAttendance(String? projectId) async {
isLoadingProjects.value = true;
final response = await ApiService.getProjects();
if (response != null && response.isNotEmpty) {
projects = response.map((e) => ProjectModel.fromJson(e)).toList();
logSafe("Projects fetched: ${projects.length}");
} else {
projects = [];
logSafe("Failed to fetch projects or no projects available.",
level: LogLevel.error);
}
isLoadingProjects.value = false;
update(['attendance_dashboard_controller']);
}
Future<void> fetchEmployeesByProject(String? projectId) async {
if (projectId == null) return; if (projectId == null) return;
isLoadingEmployees.value = true; isLoadingEmployees.value = true;
final response = await ApiService.getEmployeesByProject(projectId); final response = await ApiService.getTodaysAttendance(
projectId,
organizationId: selectedOrganization?.id,
);
if (response != null) { if (response != null) {
employees = response.map((e) => EmployeeModel.fromJson(e)).toList(); employees = response.map((e) => EmployeeModel.fromJson(e)).toList();
for (var emp in employees) { for (var emp in employees) {
@ -141,6 +136,20 @@ class AttendanceController extends GetxController {
update(); update();
} }
Future<void> fetchOrganizations(String projectId) async {
isLoadingOrganizations.value = true;
final response = await ApiService.getAssignedOrganizations(projectId);
if (response != null) {
organizations = response.data;
logSafe("Organizations fetched: ${organizations.length}");
} else {
logSafe("Failed to fetch organizations for project $projectId",
level: LogLevel.error);
}
isLoadingOrganizations.value = false;
update();
}
// ------------------ Attendance Capture ------------------ // ------------------ Attendance Capture ------------------
Future<bool> captureAndUploadAttendance( Future<bool> captureAndUploadAttendance(
@ -262,8 +271,12 @@ class AttendanceController extends GetxController {
isLoadingAttendanceLogs.value = true; isLoadingAttendanceLogs.value = true;
final response = await ApiService.getAttendanceLogs(projectId, final response = await ApiService.getAttendanceLogs(
dateFrom: dateFrom, dateTo: dateTo); projectId,
dateFrom: dateFrom,
dateTo: dateTo,
organizationId: selectedOrganization?.id,
);
if (response != null) { if (response != null) {
attendanceLogs = attendanceLogs =
response.map((e) => AttendanceLogModel.fromJson(e)).toList(); response.map((e) => AttendanceLogModel.fromJson(e)).toList();
@ -306,7 +319,10 @@ class AttendanceController extends GetxController {
isLoadingRegularizationLogs.value = true; isLoadingRegularizationLogs.value = true;
final response = await ApiService.getRegularizationLogs(projectId); final response = await ApiService.getRegularizationLogs(
projectId,
organizationId: selectedOrganization?.id,
);
if (response != null) { if (response != null) {
regularizationLogs = regularizationLogs =
response.map((e) => RegularizationLogModel.fromJson(e)).toList(); response.map((e) => RegularizationLogModel.fromJson(e)).toList();
@ -354,14 +370,28 @@ class AttendanceController extends GetxController {
Future<void> fetchProjectData(String? projectId) async { Future<void> fetchProjectData(String? projectId) async {
if (projectId == null) return; if (projectId == null) return;
await Future.wait([ await fetchOrganizations(projectId);
fetchEmployeesByProject(projectId),
fetchAttendanceLogs(projectId,
dateFrom: startDateAttendance, dateTo: endDateAttendance),
fetchRegularizationLogs(projectId),
]);
logSafe("Project data fetched for project ID: $projectId"); // Call APIs depending on the selected tab only
switch (selectedTab) {
case 'todaysAttendance':
await fetchTodaysAttendance(projectId);
break;
case 'attendanceLogs':
await fetchAttendanceLogs(
projectId,
dateFrom: startDateAttendance,
dateTo: endDateAttendance,
);
break;
case 'regularizationRequests':
await fetchRegularizationLogs(projectId);
break;
}
logSafe(
"Project data fetched for project ID: $projectId, tab: $selectedTab");
update();
} }
// ------------------ UI Interaction ------------------ // ------------------ UI Interaction ------------------

View File

@ -14,6 +14,7 @@ class LoginController extends MyController {
final RxBool isLoading = false.obs; final RxBool isLoading = false.obs;
final RxBool showPassword = false.obs; final RxBool showPassword = false.obs;
final RxBool isChecked = false.obs; final RxBool isChecked = false.obs;
final RxBool showSplash = false.obs;
@override @override
void onInit() { void onInit() {
@ -40,18 +41,14 @@ class LoginController extends MyController {
); );
} }
void onChangeCheckBox(bool? value) { void onChangeCheckBox(bool? value) => isChecked.value = value ?? false;
isChecked.value = value ?? false;
}
void onChangeShowPassword() { void onChangeShowPassword() => showPassword.toggle();
showPassword.toggle();
}
Future<void> onLogin() async { Future<void> onLogin() async {
if (!basicValidator.validateForm()) return; if (!basicValidator.validateForm()) return;
isLoading.value = true; showSplash.value = true;
try { try {
final loginData = basicValidator.getData(); final loginData = basicValidator.getData();
@ -60,50 +57,30 @@ class LoginController extends MyController {
final errors = await AuthService.loginUser(loginData); final errors = await AuthService.loginUser(loginData);
if (errors != null) { if (errors != null) {
logSafe(
"Login failed for user: ${loginData['username']} with errors: $errors",
level: LogLevel.warning);
showAppSnackbar( showAppSnackbar(
title: "Login Failed", title: "Login Failed",
message: "Username or password is incorrect", message: "Username or password is incorrect",
type: SnackbarType.error, type: SnackbarType.error,
); );
basicValidator.addErrors(errors); basicValidator.addErrors(errors);
basicValidator.validateForm(); basicValidator.validateForm();
basicValidator.clearErrors(); basicValidator.clearErrors();
} else { } else {
await _handleRememberMe(); await _handleRememberMe();
// Enable remote logging after successful login
enableRemoteLogging(); enableRemoteLogging();
logSafe("✅ Remote logging enabled after login.");
final fcmToken = await LocalStorage.getFcmToken();
if (fcmToken?.isNotEmpty ?? false) {
final success = await AuthService.registerDeviceToken(fcmToken!);
logSafe(
success
? "✅ FCM token registered after login."
: "⚠️ Failed to register FCM token after login.",
level: LogLevel.warning);
}
logSafe("Login successful for user: ${loginData['username']}"); logSafe("Login successful for user: ${loginData['username']}");
Get.toNamed('/home'); Get.offNamed('/select-tenant');
} }
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Exception during login",
level: LogLevel.error, error: e, stackTrace: stacktrace);
showAppSnackbar( showAppSnackbar(
title: "Login Error", title: "Login Error",
message: "An unexpected error occurred", message: "An unexpected error occurred",
type: SnackbarType.error, type: SnackbarType.error,
); );
logSafe("Exception during login",
level: LogLevel.error, error: e, stackTrace: stacktrace);
} finally { } finally {
isLoading.value = false; showSplash.value = false;
} }
} }
@ -135,11 +112,7 @@ class LoginController extends MyController {
} }
} }
void goToForgotPassword() { void goToForgotPassword() => Get.toNamed('/auth/forgot_password');
Get.toNamed('/auth/forgot_password');
}
void gotoRegister() { void gotoRegister() => Get.offAndToNamed('/auth/register_account');
Get.offAndToNamed('/auth/register_account');
}
} }

View File

@ -4,9 +4,10 @@ import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/view/dashboard/dashboard_screen.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart'; import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/project_controller.dart';
class MPINController extends GetxController { class MPINController extends GetxController {
final MyFormValidator basicValidator = MyFormValidator(); final MyFormValidator basicValidator = MyFormValidator();
@ -138,16 +139,17 @@ class MPINController extends GetxController {
} }
/// Navigate to dashboard /// Navigate to dashboard
void _navigateToDashboard({String? message}) { /// Navigate to tenant selection after MPIN verification
void _navigateToTenantSelection({String? message}) {
if (message != null) { if (message != null) {
logSafe("Navigating to Dashboard with message: $message"); logSafe("Navigating to Tenant Selection with message: $message");
showAppSnackbar( showAppSnackbar(
title: "Success", title: "Success",
message: message, message: message,
type: SnackbarType.success, type: SnackbarType.success,
); );
} }
Get.offAll(() => const DashboardScreen()); Get.offAllNamed('/select-tenant');
} }
/// Clear the primary MPIN fields /// Clear the primary MPIN fields
@ -239,15 +241,12 @@ class MPINController extends GetxController {
logSafe("verifyMPIN triggered"); logSafe("verifyMPIN triggered");
final enteredMPIN = digitControllers.map((c) => c.text).join(); final enteredMPIN = digitControllers.map((c) => c.text).join();
logSafe("Entered MPIN: $enteredMPIN");
if (enteredMPIN.length < 4) { if (enteredMPIN.length < 4) {
_showError("Please enter all 4 digits."); _showError("Please enter all 4 digits.");
return; return;
} }
final mpinToken = await LocalStorage.getMpinToken(); final mpinToken = await LocalStorage.getMpinToken();
if (mpinToken == null || mpinToken.isEmpty) { if (mpinToken == null || mpinToken.isEmpty) {
_showError("Missing MPIN token. Please log in again."); _showError("Missing MPIN token. Please log in again.");
return; return;
@ -270,12 +269,25 @@ class MPINController extends GetxController {
logSafe("MPIN verified successfully"); logSafe("MPIN verified successfully");
await LocalStorage.setBool('mpin_verified', true); await LocalStorage.setBool('mpin_verified', true);
// 🔹 Ensure controllers are injected and loaded
final token = await LocalStorage.getJwtToken();
if (token != null && token.isNotEmpty) {
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
await Get.find<PermissionController>().loadData(token);
}
if (!Get.isRegistered<ProjectController>()) {
Get.put(ProjectController(), permanent: true);
await Get.find<ProjectController>().fetchProjects();
}
}
showAppSnackbar( showAppSnackbar(
title: "Success", title: "Success",
message: "MPIN Verified Successfully", message: "MPIN Verified Successfully",
type: SnackbarType.success, type: SnackbarType.success,
); );
_navigateToDashboard(); _navigateToTenantSelection();
} else { } else {
final errorMessage = response["error"] ?? "Invalid MPIN"; final errorMessage = response["error"] ?? "Invalid MPIN";
logSafe("MPIN verification failed: $errorMessage", logSafe("MPIN verification failed: $errorMessage",
@ -291,11 +303,7 @@ class MPINController extends GetxController {
} catch (e) { } catch (e) {
isLoading.value = false; isLoading.value = false;
logSafe("Exception in verifyMPIN", level: LogLevel.error, error: e); logSafe("Exception in verifyMPIN", level: LogLevel.error, error: e);
showAppSnackbar( _showError("Something went wrong. Please try again.");
title: "Error",
message: "Something went wrong. Please try again.",
type: SnackbarType.error,
);
} }
} }

View File

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

View File

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

View File

@ -46,8 +46,9 @@ class DashboardController extends GetxController {
// Common ranges // Common ranges
final List<String> ranges = ['7D', '15D', '30D']; final List<String> ranges = ['7D', '15D', '30D'];
// Inject ProjectController // Inside your DashboardController
final ProjectController projectController = Get.find<ProjectController>(); final ProjectController projectController =
Get.put(ProjectController(), permanent: true);
@override @override
void onInit() { void onInit() {
@ -70,9 +71,9 @@ class DashboardController extends GetxController {
ever(projectSelectedRange, (_) => fetchProjectProgress()); ever(projectSelectedRange, (_) => fetchProjectProgress());
} }
/// ========================= // =========================
/// Helper Methods // Helper Methods
/// ========================= // =========================
int _getDaysFromRange(String range) { int _getDaysFromRange(String range) {
switch (range) { switch (range) {
case '7D': case '7D':
@ -114,21 +115,28 @@ class DashboardController extends GetxController {
logSafe('Project chart view toggled to: $isChart', level: LogLevel.debug); logSafe('Project chart view toggled to: $isChart', level: LogLevel.debug);
} }
/// ========================= // =========================
/// Manual refresh // Manual Refresh Methods
/// ========================= // =========================
Future<void> refreshDashboard() async { Future<void> refreshDashboard() async {
logSafe('Manual dashboard refresh triggered.', level: LogLevel.debug); logSafe('Manual dashboard refresh triggered.', level: LogLevel.debug);
await fetchAllDashboardData(); await fetchAllDashboardData();
} }
/// ========================= Future<void> refreshAttendance() async => fetchRoleWiseAttendance();
/// Fetch all dashboard data Future<void> refreshTasks() async {
/// ========================= final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) await fetchDashboardTasks(projectId: projectId);
}
Future<void> refreshProjects() async => fetchProjectProgress();
// =========================
// Fetch All Dashboard Data
// =========================
Future<void> fetchAllDashboardData() async { Future<void> fetchAllDashboardData() async {
final String projectId = projectController.selectedProjectId.value; final String projectId = projectController.selectedProjectId.value;
// Skip fetching if no project is selected
if (projectId.isEmpty) { if (projectId.isEmpty) {
logSafe('No project selected. Skipping dashboard API calls.', logSafe('No project selected. Skipping dashboard API calls.',
level: LogLevel.warning); level: LogLevel.warning);
@ -143,17 +151,15 @@ class DashboardController extends GetxController {
]); ]);
} }
/// ========================= // =========================
/// API Calls // API Calls
/// ========================= // =========================
Future<void> fetchRoleWiseAttendance() async { Future<void> fetchRoleWiseAttendance() async {
final String projectId = projectController.selectedProjectId.value; final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return; if (projectId.isEmpty) return;
try { try {
isAttendanceLoading.value = true; isAttendanceLoading.value = true;
final List<dynamic>? response = final List<dynamic>? response =
await ApiService.getDashboardAttendanceOverview( await ApiService.getDashboardAttendanceOverview(
projectId, getAttendanceDays()); projectId, getAttendanceDays());
@ -179,16 +185,12 @@ class DashboardController extends GetxController {
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;
try { try {
isProjectLoading.value = true; isProjectLoading.value = true;
final response = await ApiService.getProjectProgress( final response = await ApiService.getProjectProgress(
projectId: projectId, projectId: projectId, days: getProjectDays());
days: getProjectDays(),
);
if (response != null && response.success) { if (response != null && response.success) {
projectChartData.value = projectChartData.value =
@ -208,11 +210,10 @@ class DashboardController extends GetxController {
} }
Future<void> fetchDashboardTasks({required String projectId}) async { Future<void> fetchDashboardTasks({required String projectId}) async {
if (projectId.isEmpty) return; // Skip if empty if (projectId.isEmpty) return;
try { try {
isTasksLoading.value = true; isTasksLoading.value = true;
final response = await ApiService.getDashboardTasks(projectId: projectId); final response = await ApiService.getDashboardTasks(projectId: projectId);
if (response != null && response.success) { if (response != null && response.success) {
@ -235,11 +236,10 @@ class DashboardController extends GetxController {
} }
Future<void> fetchDashboardTeams({required String projectId}) async { Future<void> fetchDashboardTeams({required String projectId}) async {
if (projectId.isEmpty) return; // Skip if empty if (projectId.isEmpty) return;
try { try {
isTeamsLoading.value = true; isTeamsLoading.value = true;
final response = await ApiService.getDashboardTeams(projectId: projectId); final response = await ApiService.getDashboardTeams(projectId: projectId);
if (response != null && response.success) { if (response != null && response.success) {

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();
@ -94,12 +94,27 @@ class AddContactController extends GetxController {
required List<Map<String, String>> phones, required List<Map<String, String>> phones,
required String address, required String address,
required String description, required String description,
String? designation,
}) async { }) async {
if (isSubmitting.value) return; if (isSubmitting.value) return;
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>()
@ -125,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;
@ -150,12 +165,14 @@ 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,
if (address.trim().isNotEmpty) "address": address.trim(), if (address.trim().isNotEmpty) "address": address.trim(),
if (description.trim().isNotEmpty) "description": description.trim(), if (description.trim().isNotEmpty) "description": description.trim(),
if (designation != null && designation.trim().isNotEmpty)
"designation": designation.trim(),
}; };
logSafe("${id != null ? 'Updating' : 'Creating'} contact"); logSafe("${id != null ? 'Updating' : 'Creating'} contact");

View File

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

View File

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

View File

@ -10,12 +10,11 @@ class DocumentController extends GetxController {
var documents = <DocumentItem>[].obs; var documents = <DocumentItem>[].obs;
var filters = Rxn<DocumentFiltersData>(); var filters = Rxn<DocumentFiltersData>();
// Selected filters // Selected filters (multi-select support)
var selectedFilter = "".obs; var selectedUploadedBy = <String>[].obs;
var selectedUploadedBy = "".obs; var selectedCategory = <String>[].obs;
var selectedCategory = "".obs; var selectedType = <String>[].obs;
var selectedType = "".obs; var selectedTag = <String>[].obs;
var selectedTag = "".obs;
// Pagination state // Pagination state
var pageNumber = 1.obs; var pageNumber = 1.obs;
@ -31,6 +30,11 @@ class DocumentController extends GetxController {
// NEW: search // NEW: search
var searchQuery = ''.obs; var searchQuery = ''.obs;
var searchController = TextEditingController(); var searchController = TextEditingController();
// New filter fields
var isUploadedAt = true.obs;
var isVerified = RxnBool();
var startDate = Rxn<String>();
var endDate = Rxn<String>();
// ------------------ API Calls ----------------------- // ------------------ API Calls -----------------------
@ -133,7 +137,7 @@ class DocumentController extends GetxController {
searchString: searchString ?? searchQuery.value, searchString: searchString ?? searchQuery.value,
pageNumber: pageNumber.value, pageNumber: pageNumber.value,
pageSize: pageSize, pageSize: pageSize,
isActive: !showInactive.value, // 👈 active or inactive isActive: !showInactive.value,
); );
if (response != null && response.success) { if (response != null && response.success) {
@ -157,17 +161,21 @@ class DocumentController extends GetxController {
/// Clear selected filters /// Clear selected filters
void clearFilters() { void clearFilters() {
selectedUploadedBy.value = ""; selectedUploadedBy.clear();
selectedCategory.value = ""; selectedCategory.clear();
selectedType.value = ""; selectedType.clear();
selectedTag.value = ""; selectedTag.clear();
isUploadedAt.value = true;
isVerified.value = null;
startDate.value = null;
endDate.value = null;
} }
/// Check if any filters are active (for red dot indicator) /// Check if any filters are active (for red dot indicator)
bool hasActiveFilters() { bool hasActiveFilters() {
return selectedUploadedBy.value.isNotEmpty || return selectedUploadedBy.isNotEmpty ||
selectedCategory.value.isNotEmpty || selectedCategory.isNotEmpty ||
selectedType.value.isNotEmpty || selectedType.isNotEmpty ||
selectedTag.value.isNotEmpty; selectedTag.isNotEmpty;
} }
} }

View File

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

View File

@ -24,7 +24,7 @@ class EmployeesScreenController extends GetxController {
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
isLoading.value = true; isLoading.value = true;
fetchAllProjects().then((_) { fetchAllProjects().then((_) {
final projectId = Get.find<ProjectController>().selectedProject?.id; final projectId = Get.find<ProjectController>().selectedProject?.id;
if (projectId != null) { if (projectId != null) {
@ -66,21 +66,26 @@ class EmployeesScreenController extends GetxController {
update(['employee_screen_controller']); update(['employee_screen_controller']);
} }
Future<void> fetchAllEmployees() async { Future<void> fetchAllEmployees({String? organizationId}) async {
isLoading.value = true; isLoading.value = true;
update(['employee_screen_controller']); update(['employee_screen_controller']);
await _handleApiCall( await _handleApiCall(
ApiService.getAllEmployees, () => ApiService.getAllEmployees(
organizationId: organizationId), // pass orgId to API
onSuccess: (data) { onSuccess: (data) {
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json))); employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
logSafe("All Employees fetched: ${employees.length} employees loaded.", logSafe(
level: LogLevel.info); "All Employees fetched: ${employees.length} employees loaded.",
level: LogLevel.info,
);
}, },
onEmpty: () { onEmpty: () {
employees.clear(); employees.clear();
logSafe("No Employee data found or API call failed.", logSafe(
level: LogLevel.warning); "No Employee data found or API call failed",
level: LogLevel.warning,
);
}, },
); );
@ -88,43 +93,22 @@ class EmployeesScreenController extends GetxController {
update(['employee_screen_controller']); update(['employee_screen_controller']);
} }
Future<void> fetchEmployeesByProject(String? projectId) async { Future<void> fetchEmployeesByProject(String projectId,
if (projectId == null || projectId.isEmpty) { {String? organizationId}) async {
logSafe("Project ID is required but was null or empty.", if (projectId.isEmpty) return;
level: LogLevel.error);
return;
}
isLoading.value = true; isLoading.value = true;
await _handleApiCall( await _handleApiCall(
() => ApiService.getAllEmployeesByProject(projectId), () => ApiService.getAllEmployeesByProject(projectId,
organizationId: organizationId),
onSuccess: (data) { onSuccess: (data) {
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json))); employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
for (var emp in employees) { for (var emp in employees) {
uploadingStates[emp.id] = false.obs; uploadingStates[emp.id] = false.obs;
} }
logSafe(
"Employees fetched: ${employees.length} for project $projectId",
level: LogLevel.info,
);
},
onEmpty: () {
employees.clear();
logSafe(
"No employees found for project $projectId.",
level: LogLevel.warning,
);
},
onError: (e) {
logSafe(
"Error fetching employees for project $projectId",
level: LogLevel.error,
error: e,
);
}, },
onEmpty: () => employees.clear(),
); );
isLoading.value = false; isLoading.value = false;

View File

@ -13,6 +13,7 @@ class PermissionController extends GetxController {
var employeeInfo = Rxn<EmployeeInfo>(); var employeeInfo = Rxn<EmployeeInfo>();
var projectsInfo = <ProjectInfo>[].obs; var projectsInfo = <ProjectInfo>[].obs;
Timer? _refreshTimer; Timer? _refreshTimer;
var isLoading = true.obs;
@override @override
void onInit() { void onInit() {
@ -26,7 +27,8 @@ class PermissionController extends GetxController {
await loadData(token!); await loadData(token!);
_startAutoRefresh(); _startAutoRefresh();
} else { } else {
logSafe("Token is null or empty. Skipping API load and auto-refresh.", level: LogLevel.warning); logSafe("Token is null or empty. Skipping API load and auto-refresh.",
level: LogLevel.warning);
} }
} }
@ -37,19 +39,24 @@ class PermissionController extends GetxController {
logSafe("Auth token retrieved: $token", level: LogLevel.debug); logSafe("Auth token retrieved: $token", level: LogLevel.debug);
return token; return token;
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error retrieving auth token", level: LogLevel.error, error: e, stackTrace: stacktrace); logSafe("Error retrieving auth token",
level: LogLevel.error, error: e, stackTrace: stacktrace);
return null; return null;
} }
} }
Future<void> loadData(String token) async { Future<void> loadData(String token) async {
try { try {
isLoading.value = true;
final userData = await PermissionService.fetchAllUserData(token); final userData = await PermissionService.fetchAllUserData(token);
_updateState(userData); _updateState(userData);
await _storeData(); await _storeData();
logSafe("Data loaded and state updated successfully."); logSafe("Data loaded and state updated successfully.");
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error loading data from API", level: LogLevel.error, error: e, stackTrace: stacktrace); logSafe("Error loading data from API",
level: LogLevel.error, error: e, stackTrace: stacktrace);
} finally {
isLoading.value = false;
} }
} }
@ -60,7 +67,8 @@ class PermissionController extends GetxController {
projectsInfo.assignAll(userData['projects']); projectsInfo.assignAll(userData['projects']);
logSafe("State updated with user data."); logSafe("State updated with user data.");
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error updating state", level: LogLevel.error, error: e, stackTrace: stacktrace); logSafe("Error updating state",
level: LogLevel.error, error: e, stackTrace: stacktrace);
} }
} }
@ -89,7 +97,8 @@ class PermissionController extends GetxController {
logSafe("User data successfully stored in SharedPreferences."); logSafe("User data successfully stored in SharedPreferences.");
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error storing data", level: LogLevel.error, error: e, stackTrace: stacktrace); logSafe("Error storing data",
level: LogLevel.error, error: e, stackTrace: stacktrace);
} }
} }
@ -100,20 +109,23 @@ class PermissionController extends GetxController {
if (token?.isNotEmpty ?? false) { if (token?.isNotEmpty ?? false) {
await loadData(token!); await loadData(token!);
} else { } else {
logSafe("Token missing during auto-refresh. Skipping.", level: LogLevel.warning); logSafe("Token missing during auto-refresh. Skipping.",
level: LogLevel.warning);
} }
}); });
} }
bool hasPermission(String permissionId) { bool hasPermission(String permissionId) {
final hasPerm = permissions.any((p) => p.id == permissionId); final hasPerm = permissions.any((p) => p.id == permissionId);
logSafe("Checking permission $permissionId: $hasPerm", level: LogLevel.debug); logSafe("Checking permission $permissionId: $hasPerm",
level: LogLevel.debug);
return hasPerm; return hasPerm;
} }
bool isUserAssignedToProject(String projectId) { bool isUserAssignedToProject(String projectId) {
final assigned = projectsInfo.any((project) => project.id == projectId); final assigned = projectsInfo.any((project) => project.id == projectId);
logSafe("Checking project assignment for $projectId: $assigned", level: LogLevel.debug); logSafe("Checking project assignment for $projectId: $assigned",
level: LogLevel.debug);
return assigned; return assigned;
} }

View File

@ -4,6 +4,7 @@ import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/project_model.dart'; import 'package:marco/model/project_model.dart';
import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart'; import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart';
import 'package:marco/model/dailyTaskPlanning/daily_progress_report_filter_response_model.dart';
class DailyTaskController extends GetxController { class DailyTaskController extends GetxController {
List<ProjectModel> projects = []; List<ProjectModel> projects = [];
@ -23,9 +24,19 @@ class DailyTaskController extends GetxController {
} }
} }
RxBool isLoading = true.obs; RxSet<String> selectedBuildings = <String>{}.obs;
Map<String, List<TaskModel>> groupedDailyTasks = {}; RxSet<String> selectedFloors = <String>{}.obs;
RxSet<String> selectedActivities = <String>{}.obs;
RxSet<String> selectedServices = <String>{}.obs;
RxBool isFilterLoading = false.obs;
RxBool isLoading = true.obs;
RxBool isLoadingMore = false.obs;
Map<String, List<TaskModel>> groupedDailyTasks = {};
// Pagination
int currentPage = 1;
int pageSize = 20;
bool hasMore = true;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
@ -47,47 +58,93 @@ class DailyTaskController extends GetxController {
); );
} }
Future<void> fetchTaskData(String? projectId) async { void clearTaskFilters() {
if (projectId == null) { selectedBuildings.clear();
logSafe("fetchTaskData: Skipped, projectId is null", selectedFloors.clear();
level: LogLevel.warning); selectedActivities.clear();
return; selectedServices.clear();
startDateTask = null;
endDateTask = null;
update();
}
Future<void> fetchTaskData(
String projectId, {
int pageNumber = 1,
int pageSize = 20,
bool isLoadMore = false,
}) async {
if (!isLoadMore) {
isLoading.value = true;
currentPage = 1;
hasMore = true;
groupedDailyTasks.clear();
dailyTasks.clear();
} else {
isLoadingMore.value = true;
} }
isLoading.value = true; // Create the filter object
final filter = {
"buildingIds": selectedBuildings.toList(),
"floorIds": selectedFloors.toList(),
"activityIds": selectedActivities.toList(),
"serviceIds": selectedServices.toList(),
"dateFrom": startDateTask?.toIso8601String(),
"dateTo": endDateTask?.toIso8601String(),
};
final response = await ApiService.getDailyTasks( final response = await ApiService.getDailyTasks(
projectId, projectId,
dateFrom: startDateTask, filter: filter,
dateTo: endDateTask, pageNumber: pageNumber,
pageSize: pageSize,
); );
isLoading.value = false; if (response != null && response.isNotEmpty) {
for (var task in response) {
if (response != null) {
groupedDailyTasks.clear();
for (var taskJson in response) {
final task = TaskModel.fromJson(taskJson);
final assignmentDateKey = final assignmentDateKey =
task.assignmentDate.toIso8601String().split('T')[0]; task.assignmentDate.toIso8601String().split('T')[0];
groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []).add(task); groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []).add(task);
} }
dailyTasks = groupedDailyTasks.values.expand((list) => list).toList(); dailyTasks = groupedDailyTasks.values.expand((list) => list).toList();
currentPage = pageNumber;
logSafe(
"Daily tasks fetched and grouped: ${dailyTasks.length} for project $projectId",
level: LogLevel.info,
);
update();
} else { } else {
logSafe( hasMore = false;
"Failed to fetch daily tasks for project $projectId", }
level: LogLevel.error,
); isLoading.value = false;
isLoadingMore.value = false;
update();
}
FilterData? taskFilterData;
Future<void> fetchTaskFilter(String projectId) async {
isFilterLoading.value = true;
try {
final filterResponse = await ApiService.getDailyTaskFilter(projectId);
if (filterResponse != null && filterResponse.success) {
taskFilterData =
filterResponse.data; // now taskFilterData is FilterData?
logSafe(
"Task filter fetched successfully. Buildings: ${taskFilterData?.buildings.length}, Floors: ${taskFilterData?.floors.length}",
level: LogLevel.info,
);
} else {
logSafe(
"Failed to fetch task filter for projectId: $projectId",
level: LogLevel.warning,
);
}
} catch (e, stack) {
logSafe("Exception in fetchTaskFilter: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
} finally {
isFilterLoading.value = false;
update();
} }
} }
@ -119,17 +176,23 @@ class DailyTaskController extends GetxController {
level: LogLevel.info, level: LogLevel.info,
); );
await controller.fetchTaskData(controller.selectedProjectId); // 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({ void refreshTasksFromNotification({
required String projectId, required String projectId,
required String taskAllocationId, required String taskAllocationId,
}) async { }) async {
// re-fetch tasks // re-fetch tasks
await fetchTaskData(projectId); await fetchTaskData(projectId);
update(); // rebuilds UI
}
update(); // rebuilds UI
}
} }

View File

@ -9,34 +9,28 @@ import 'package:marco/model/employees/employee_model.dart';
class DailyTaskPlanningController extends GetxController { class DailyTaskPlanningController extends GetxController {
List<ProjectModel> projects = []; List<ProjectModel> projects = [];
List<EmployeeModel> employees = []; RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
List<TaskPlanningDetailsModel> dailyTasks = [];
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs; RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs;
List<EmployeeModel> allEmployeesCache = [];
List<TaskPlanningDetailsModel> dailyTasks = [];
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
MyFormValidator basicValidator = MyFormValidator(); MyFormValidator basicValidator = MyFormValidator();
List<Map<String, dynamic>> roles = []; List<Map<String, dynamic>> roles = [];
RxBool isAssigningTask = false.obs; RxBool isAssigningTask = false.obs;
RxnString selectedRoleId = RxnString(); RxnString selectedRoleId = RxnString();
RxBool isLoading = false.obs; RxBool isFetchingTasks = true.obs;
RxBool isFetchingProjects = true.obs;
RxBool isFetchingEmployees = true.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
fetchRoles(); fetchRoles();
_initializeDefaults();
}
void _initializeDefaults() {
fetchProjects();
} }
String? formFieldValidator(String? value, {required String fieldType}) { String? formFieldValidator(String? value, {required String fieldType}) {
if (value == null || value.trim().isEmpty) { if (value == null || value.trim().isEmpty) return 'This field is required';
return 'This field is required';
}
if (fieldType == "target" && int.tryParse(value.trim()) == null) { if (fieldType == "target" && int.tryParse(value.trim()) == null) {
return 'Please enter a valid number'; return 'Please enter a valid number';
} }
@ -47,21 +41,14 @@ class DailyTaskPlanningController extends GetxController {
} }
void updateSelectedEmployees() { void updateSelectedEmployees() {
final selected = selectedEmployees.value =
employees.where((e) => uploadingStates[e.id]?.value == true).toList(); employees.where((e) => uploadingStates[e.id]?.value == true).toList();
selectedEmployees.value = selected; logSafe("Updated selected employees", level: LogLevel.debug);
logSafe(
"Updated selected employees",
level: LogLevel.debug,
);
} }
void onRoleSelected(String? roleId) { void onRoleSelected(String? roleId) {
selectedRoleId.value = roleId; selectedRoleId.value = roleId;
logSafe( logSafe("Role selected", level: LogLevel.info);
"Role selected",
level: LogLevel.info,
);
} }
Future<void> fetchRoles() async { Future<void> fetchRoles() async {
@ -82,6 +69,8 @@ class DailyTaskPlanningController extends GetxController {
required String description, required String description,
required List<String> taskTeam, required List<String> taskTeam,
DateTime? assignmentDate, DateTime? assignmentDate,
String? organizationId,
String? serviceId,
}) async { }) async {
isAssigningTask.value = true; isAssigningTask.value = true;
logSafe("Starting assign task...", level: LogLevel.info); logSafe("Starting assign task...", level: LogLevel.info);
@ -92,9 +81,11 @@ class DailyTaskPlanningController extends GetxController {
description: description, description: description,
taskTeam: taskTeam, taskTeam: taskTeam,
assignmentDate: assignmentDate, assignmentDate: assignmentDate,
organizationId: organizationId,
serviceId: serviceId,
); );
isAssigningTask.value = false; isAssigningTask.value = false;
if (response == true) { if (response == true) {
logSafe("Task assigned successfully", level: LogLevel.info); logSafe("Task assigned successfully", level: LogLevel.info);
@ -115,92 +106,158 @@ class DailyTaskPlanningController extends GetxController {
} }
} }
Future<void> fetchProjects() async { /// Fetch Infra details and then tasks per work area
isLoading.value = true; Future<void> fetchTaskData(String? projectId, {String? serviceId}) async {
if (projectId == null) return;
isFetchingTasks.value = true;
try { try {
final response = await ApiService.getProjects(); final infraResponse = await ApiService.getInfraDetails(
if (response?.isEmpty ?? true) { projectId,
logSafe("No project data found or API call failed", serviceId: serviceId,
level: LogLevel.warning); );
final infraData = infraResponse?['data'] as List<dynamic>?;
if (infraData == null || infraData.isEmpty) {
dailyTasks = [];
return; return;
} }
projects = response!.map((json) => ProjectModel.fromJson(json)).toList(); dailyTasks = infraData.map((buildingJson) {
logSafe("Projects fetched: ${projects.length} projects loaded", final building = Building(
level: LogLevel.info); id: buildingJson['id'],
update(); name: buildingJson['buildingName'],
} catch (e, stack) { description: buildingJson['description'],
logSafe("Error fetching projects", floors: (buildingJson['floors'] as List<dynamic>)
level: LogLevel.error, error: e, stackTrace: stack); .map((floorJson) => Floor(
} finally { id: floorJson['id'],
isLoading.value = false; floorName: floorJson['floorName'],
} workAreas: (floorJson['workAreas'] as List<dynamic>)
} .map((areaJson) => WorkArea(
id: areaJson['id'],
Future<void> fetchTaskData(String? projectId) async { areaName: areaJson['areaName'],
if (projectId == null) { workItems: [],
logSafe("Project ID is null", level: LogLevel.warning); ))
return; .toList(),
} ))
.toList(),
isLoading.value = true;
try {
final response = await ApiService.getDailyTasksDetails(projectId);
final data = response?['data'];
if (data != null) {
dailyTasks = [TaskPlanningDetailsModel.fromJson(data)];
logSafe(
"Daily task Planning Details fetched",
level: LogLevel.info,
); );
} else { return TaskPlanningDetailsModel(
logSafe("Data field is null", level: LogLevel.warning); id: building.id,
} name: building.name,
projectAddress: "",
contactPerson: "",
startDate: DateTime.now(),
endDate: DateTime.now(),
projectStatusId: "",
buildings: [building],
);
}).toList();
await Future.wait(dailyTasks
.expand((task) => task.buildings)
.expand((b) => b.floors)
.expand((f) => f.workAreas)
.map((area) async {
try {
final taskResponse =
await ApiService.getWorkItemsByWorkArea(area.id, serviceId: serviceId);
final taskData = taskResponse?['data'] as List<dynamic>? ?? [];
area.workItems.addAll(taskData.map((taskJson) => WorkItemWrapper(
workItemId: taskJson['id'],
workItem: WorkItem(
id: taskJson['id'],
activityMaster: taskJson['activityMaster'] != null
? ActivityMaster.fromJson(taskJson['activityMaster'])
: null,
workCategoryMaster: taskJson['workCategoryMaster'] != null
? WorkCategoryMaster.fromJson(taskJson['workCategoryMaster'])
: null,
plannedWork: (taskJson['plannedWork'] as num?)?.toDouble(),
completedWork: (taskJson['completedWork'] as num?)?.toDouble(),
todaysAssigned: (taskJson['todaysAssigned'] as num?)?.toDouble(),
description: taskJson['description'] as String?,
taskDate: taskJson['taskDate'] != null
? DateTime.tryParse(taskJson['taskDate'])
: null,
),
)));
} catch (e, stack) {
logSafe("Error fetching tasks for work area ${area.id}",
level: LogLevel.error, error: e, stackTrace: stack);
}
}));
} catch (e, stack) { } catch (e, stack) {
logSafe("Error fetching daily task data", logSafe("Error fetching daily task data",
level: LogLevel.error, error: e, stackTrace: stack); level: LogLevel.error, error: e, stackTrace: stack);
} finally { } finally {
isLoading.value = false; isFetchingTasks.value = false;
update(); update();
} }
} }
Future<void> fetchEmployeesByProject(String? projectId) async { Future<void> fetchEmployeesByProjectService({
if (projectId == null || projectId.isEmpty) { required String projectId,
logSafe("Project ID is required but was null or empty", String? serviceId,
level: LogLevel.error); String? organizationId,
return; }) async {
} isFetchingEmployees.value = true;
isLoading.value = true;
try { try {
final response = await ApiService.getAllEmployeesByProject(projectId); final response = await ApiService.getEmployeesByProjectService(
projectId,
serviceId: serviceId ?? '',
organizationId: organizationId ?? '',
);
if (response != null && response.isNotEmpty) { if (response != null && response.isNotEmpty) {
employees = employees.assignAll(response.map((json) => EmployeeModel.fromJson(json)));
response.map((json) => EmployeeModel.fromJson(json)).toList();
for (var emp in employees) { if (serviceId == null && organizationId == null) {
uploadingStates[emp.id] = false.obs; allEmployeesCache = List.from(employees);
} }
logSafe(
"Employees fetched: ${employees.length} for project $projectId", final currentEmployeeIds = employees.map((e) => e.id).toSet();
level: LogLevel.info,
); uploadingStates.removeWhere((key, _) => !currentEmployeeIds.contains(key));
employees.forEach((emp) {
uploadingStates.putIfAbsent(emp.id, () => false.obs);
});
selectedEmployees.removeWhere((e) => !currentEmployeeIds.contains(e.id));
logSafe("Employees fetched: ${employees.length}", level: LogLevel.info);
} else { } else {
employees = []; employees.clear();
uploadingStates.clear();
selectedEmployees.clear();
logSafe( logSafe(
"No employees found for project $projectId", serviceId != null || organizationId != null
? "Filtered employees empty"
: "No employees found",
level: LogLevel.warning, level: LogLevel.warning,
); );
} }
} catch (e, stack) { } catch (e, stack) {
logSafe( logSafe("Error fetching employees", level: LogLevel.error, error: e, stackTrace: stack);
"Error fetching employees for project $projectId",
level: LogLevel.error, if (serviceId == null && organizationId == null && allEmployeesCache.isNotEmpty) {
error: e, employees.assignAll(allEmployeesCache);
stackTrace: stack,
); final cachedEmployeeIds = employees.map((e) => e.id).toSet();
uploadingStates.removeWhere((key, _) => !cachedEmployeeIds.contains(key));
employees.forEach((emp) {
uploadingStates.putIfAbsent(emp.id, () => false.obs);
});
selectedEmployees.removeWhere((e) => !cachedEmployeeIds.contains(e.id));
} else {
employees.clear();
uploadingStates.clear();
selectedEmployees.clear();
}
} finally { } finally {
isLoading.value = false; isFetchingEmployees.value = false;
update(); update();
} }
} }

View File

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

View File

@ -0,0 +1,52 @@
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

@ -0,0 +1,43 @@
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

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

View File

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

View File

@ -1,6 +1,6 @@
class ApiEndpoints { class ApiEndpoints {
// static const String baseUrl = "https://stageapi.marcoaiot.com/api"; static const String baseUrl = "https://stageapi.marcoaiot.com/api";
static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api"; // static const String baseUrl = "https://devapi.marcoaiot.com/api";
// Dashboard Module API Endpoints // Dashboard Module API Endpoints
@ -14,7 +14,7 @@ class ApiEndpoints {
// Attendance Module API Endpoints // Attendance Module API Endpoints
static const String getProjects = "/project/list"; static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic"; static const String getGlobalProjects = "/project/list/basic";
static const String getEmployeesByProject = "/attendance/project/team"; static const String getTodaysAttendance = "/attendance/project/team";
static const String getAttendanceLogs = "/attendance/project/log"; static const String getAttendanceLogs = "/attendance/project/log";
static const String getAttendanceLogView = "/attendance/log/attendance"; static const String getAttendanceLogView = "/attendance/log/attendance";
static const String getRegularizationLogs = "/attendance/regularize"; static const String getRegularizationLogs = "/attendance/regularize";
@ -22,10 +22,11 @@ class ApiEndpoints {
// Employee Screen API Endpoints // Employee Screen API Endpoints
static const String getAllEmployeesByProject = "/employee/list"; static const String getAllEmployeesByProject = "/employee/list";
static const String getAllEmployeesByOrganization = "/project/get/task/team";
static const String getAllEmployees = "/employee/list"; static const String getAllEmployees = "/employee/list";
static const String getEmployeesWithoutPermission = "/employee/basic"; static const String getEmployeesWithoutPermission = "/employee/basic";
static const String getRoles = "/roles/jobrole"; static const String getRoles = "/roles/jobrole";
static const String createEmployee = "/employee/manage-mobile"; static const String createEmployee = "/employee/app/manage";
static const String getEmployeeInfo = "/employee/profile/get"; static const String getEmployeeInfo = "/employee/profile/get";
static const String assignEmployee = "/employee/profile/get"; static const String assignEmployee = "/employee/profile/get";
static const String getAssignedProjects = "/project/assigned-projects"; static const String getAssignedProjects = "/project/assigned-projects";
@ -41,6 +42,7 @@ class ApiEndpoints {
static const String approveReportAction = "/task/approve"; static const String approveReportAction = "/task/approve";
static const String assignTask = "/project/task"; static const String assignTask = "/project/task";
static const String getmasterWorkCategories = "/Master/work-categories"; static const String getmasterWorkCategories = "/Master/work-categories";
static const String getDailyTaskProjectProgressFilter = "/task/filter";
////// Directory Module API Endpoints /////// ////// Directory Module API Endpoints ///////
static const String getDirectoryContacts = "/directory"; static const String getDirectoryContacts = "/directory";
@ -52,6 +54,8 @@ class ApiEndpoints {
static const String getDirectoryOrganization = "/directory/organization"; static const String getDirectoryOrganization = "/directory/organization";
static const String createContact = "/directory"; static const String createContact = "/directory";
static const String updateContact = "/directory"; static const String updateContact = "/directory";
static const String deleteContact = "/directory";
static const String restoreContact = "/directory/note";
static const String getDirectoryNotes = "/directory/notes"; static const String getDirectoryNotes = "/directory/notes";
static const String updateDirectoryNotes = "/directory/note"; static const String updateDirectoryNotes = "/directory/note";
static const String createBucket = "/directory/bucket"; static const String createBucket = "/directory/bucket";
@ -90,4 +94,10 @@ class ApiEndpoints {
/// Logs Module API Endpoints /// Logs Module API Endpoints
static const String uploadLogs = "/log"; static const String uploadLogs = "/log";
static const String getAssignedOrganizations =
"/project/get/assigned/organization";
static const getAllOrganizations = "/organization/list";
static const String getAssignedServices = "/Project/get/assigned/services";
} }

View File

@ -18,9 +18,13 @@ import 'package:marco/model/document/master_document_tags.dart';
import 'package:marco/model/document/master_document_type_model.dart'; 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/tenant/tenant_services_model.dart';
import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart';
import 'package:marco/model/dailyTaskPlanning/daily_progress_report_filter_response_model.dart';
import 'package:marco/model/all_organization_model.dart';
class ApiService { class ApiService {
static const Duration timeout = Duration(seconds: 30);
static const bool enableLogs = true; static const bool enableLogs = true;
static const Duration extendedTimeout = Duration(seconds: 60); static const Duration extendedTimeout = Duration(seconds: 60);
@ -137,8 +141,9 @@ class ApiService {
logSafe("Headers: ${_headers(token)}", level: LogLevel.debug); logSafe("Headers: ${_headers(token)}", level: LogLevel.debug);
try { try {
final response = final response = await http
await http.get(uri, headers: _headers(token)).timeout(timeout); .get(uri, headers: _headers(token))
.timeout(extendedTimeout);
logSafe("Response Status: ${response.statusCode}", level: LogLevel.debug); logSafe("Response Status: ${response.statusCode}", level: LogLevel.debug);
logSafe("Response Body: ${response.body}", level: LogLevel.debug); logSafe("Response Body: ${response.body}", level: LogLevel.debug);
@ -172,7 +177,7 @@ class ApiService {
static Future<http.Response?> _postRequest( static Future<http.Response?> _postRequest(
String endpoint, String endpoint,
dynamic body, { dynamic body, {
Duration customTimeout = timeout, Duration customTimeout = extendedTimeout,
bool hasRetried = false, bool hasRetried = false,
}) async { }) async {
String? token = await _getToken(); String? token = await _getToken();
@ -206,7 +211,7 @@ class ApiService {
String endpoint, String endpoint,
dynamic body, { dynamic body, {
Map<String, String>? additionalHeaders, Map<String, String>? additionalHeaders,
Duration customTimeout = timeout, Duration customTimeout = extendedTimeout,
bool hasRetried = false, bool hasRetried = false,
}) async { }) async {
String? token = await _getToken(); String? token = await _getToken();
@ -247,19 +252,155 @@ class ApiService {
} }
} }
static Future<http.Response?> _deleteRequest(
String endpoint, {
Map<String, String>? additionalHeaders,
Duration customTimeout = extendedTimeout,
bool hasRetried = false,
}) async {
String? token = await _getToken();
if (token == null) return null;
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint");
final headers = {
..._headers(token),
if (additionalHeaders != null) ...additionalHeaders,
};
logSafe("DELETE $uri\nHeaders: $headers");
try {
final response =
await http.delete(uri, headers: headers).timeout(customTimeout);
if (response.statusCode == 401 && !hasRetried) {
logSafe("Unauthorized DELETE. Attempting token refresh...");
if (await AuthService.refreshToken()) {
return await _deleteRequest(
endpoint,
additionalHeaders: additionalHeaders,
customTimeout: customTimeout,
hasRetried: true,
);
}
}
return response;
} catch (e) {
logSafe("HTTP DELETE Exception: $e", level: LogLevel.error);
return null;
}
}
/// Get Organizations assigned to a Project
static Future<OrganizationListResponse?> getAssignedOrganizations(
String projectId) async {
final endpoint = "${ApiEndpoints.getAssignedOrganizations}/$projectId";
logSafe("Fetching organizations assigned to projectId: $projectId");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Assigned Organizations request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse =
_parseResponseForAllData(response, label: "Assigned Organizations");
if (jsonResponse != null) {
return OrganizationListResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getAssignedOrganizations: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
static Future<AllOrganizationListResponse?> getAllOrganizations() async {
final endpoint = "${ApiEndpoints.getAllOrganizations}";
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("All Organizations request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse =
_parseResponseForAllData(response, label: "All Organizations");
if (jsonResponse != null) {
return AllOrganizationListResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getAllOrganizations: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
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}");
try { try {
final response = // Get token directly without triggering logout or refresh
await _postRequest(endpoint, logs, customTimeout: extendedTimeout); final token = await LocalStorage.getJwtToken();
if (token == null) {
if (response == null) { logSafe("No token available. Skipping logs post.",
logSafe("Post logs failed: null response", level: LogLevel.error); level: LogLevel.warning);
return false; return false;
} }
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint");
final headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
};
final response = await http
.post(uri, headers: headers, body: jsonEncode(logs))
.timeout(ApiService.extendedTimeout);
logSafe("Post logs response status: ${response.statusCode}"); logSafe("Post logs response status: ${response.statusCode}");
logSafe("Post logs response body: ${response.body}"); logSafe("Post logs response body: ${response.body}");
@ -868,8 +1009,9 @@ class ApiService {
logSafe("Sending DELETE request to $uri", level: LogLevel.debug); logSafe("Sending DELETE request to $uri", level: LogLevel.debug);
final response = final response = await http
await http.delete(uri, headers: _headers(token)).timeout(timeout); .delete(uri, headers: _headers(token))
.timeout(extendedTimeout);
logSafe("DELETE expense response status: ${response.statusCode}"); logSafe("DELETE expense response status: ${response.statusCode}");
logSafe("DELETE expense response body: ${response.body}"); logSafe("DELETE expense response body: ${response.body}");
@ -1281,8 +1423,9 @@ class ApiService {
logSafe("Sending DELETE request to $uri", level: LogLevel.debug); logSafe("Sending DELETE request to $uri", level: LogLevel.debug);
final response = final response = await http
await http.delete(uri, headers: _headers(token)).timeout(timeout); .delete(uri, headers: _headers(token))
.timeout(extendedTimeout);
logSafe("DELETE bucket response status: ${response.statusCode}"); logSafe("DELETE bucket response status: ${response.statusCode}");
logSafe("DELETE bucket response body: ${response.body}"); logSafe("DELETE bucket response body: ${response.body}");
@ -1615,8 +1758,53 @@ class ApiService {
return false; return false;
} }
static Future<List<dynamic>?> getDirectoryComments(String contactId) async { static Future<bool> restoreContactComment(
final url = "${ApiEndpoints.getDirectoryNotes}/$contactId"; String commentId,
bool isActive,
) async {
final endpoint =
"${ApiEndpoints.updateDirectoryNotes}/$commentId?active=$isActive";
logSafe(
"Updating comment active status. commentId: $commentId, isActive: $isActive");
logSafe("Sending request to $endpoint ");
try {
final response = await _deleteRequest(
endpoint,
);
if (response == null) {
logSafe("Update comment failed: null response", level: LogLevel.error);
return false;
}
logSafe("Update comment response status: ${response.statusCode}");
logSafe("Update comment response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe(
"Comment active status updated successfully. commentId: $commentId");
return true;
} else {
logSafe("Failed to update comment: ${json['message']}",
level: LogLevel.warning);
}
} catch (e, stack) {
logSafe("Exception during updateComment API: ${e.toString()}",
level: LogLevel.error);
logSafe("StackTrace: ${stack.toString()}", level: LogLevel.debug);
}
return false;
}
static Future<List<dynamic>?> getDirectoryComments(
String contactId, {
bool active = true,
}) async {
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')
@ -1625,6 +1813,52 @@ class ApiService {
return data is List ? data : null; return data is List ? data : null;
} }
/// Deletes a directory contact (sets active=false)
static Future<bool> deleteDirectoryContact(String contactId) async {
final endpoint = "${ApiEndpoints.updateContact}/$contactId/";
final queryParams = {'active': 'false'};
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint")
.replace(queryParameters: queryParams);
_log("Deleting directory contact at $uri");
final response = await _deleteRequest(
"$endpoint?active=false",
);
if (response != null && response.statusCode == 200) {
_log("Contact deleted successfully: ${response.body}");
return true;
}
_log("Failed to delete contact: ${response?.body}");
return false;
}
/// Restores a directory contact (sets active=true)
static Future<bool> restoreDirectoryContact(String contactId) async {
final endpoint = "${ApiEndpoints.updateContact}/$contactId/";
final queryParams = {'active': 'true'};
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint")
.replace(queryParameters: queryParams);
_log("Restoring directory contact at $uri");
final response = await _deleteRequest(
"$endpoint?active=true",
);
if (response != null && response.statusCode == 200) {
_log("Contact restored successfully: ${response.body}");
return true;
}
_log("Failed to restore contact: ${response?.body}");
return false;
}
static Future<bool> updateContact( static Future<bool> updateContact(
String contactId, Map<String, dynamic> payload) async { String contactId, Map<String, dynamic> payload) async {
try { try {
@ -1733,23 +1967,49 @@ class ApiService {
_getRequest(ApiEndpoints.getGlobalProjects).then((res) => _getRequest(ApiEndpoints.getGlobalProjects).then((res) =>
res != null ? _parseResponse(res, label: 'Global Projects') : null); res != null ? _parseResponse(res, label: 'Global Projects') : null);
static Future<List<dynamic>?> getEmployeesByProject(String projectId) async => static Future<List<dynamic>?> getTodaysAttendance(
_getRequest(ApiEndpoints.getEmployeesByProject, String projectId, {
queryParams: {"projectId": projectId}) String? organizationId,
.then((res) => }) async {
res != null ? _parseResponse(res, label: 'Employees') : null); final query = {
"projectId": projectId,
if (organizationId != null) "organizationId": organizationId,
};
return _getRequest(ApiEndpoints.getTodaysAttendance, queryParams: query)
.then((res) =>
res != null ? _parseResponse(res, label: 'Employees') : null);
}
static Future<List<dynamic>?> getRegularizationLogs(
String projectId, {
String? organizationId,
}) async {
final query = {
"projectId": projectId,
if (organizationId != null) "organizationId": organizationId,
};
return _getRequest(ApiEndpoints.getRegularizationLogs, queryParams: query)
.then((res) => res != null
? _parseResponse(res, label: 'Regularization Logs')
: null);
}
static Future<List<dynamic>?> getAttendanceLogs( static Future<List<dynamic>?> getAttendanceLogs(
String projectId, { String projectId, {
DateTime? dateFrom, DateTime? dateFrom,
DateTime? dateTo, DateTime? dateTo,
String? organizationId,
}) async { }) async {
final query = { final query = {
"projectId": projectId, "projectId": projectId,
if (dateFrom != null) if (dateFrom != null)
"dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom), "dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom),
if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo), if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo),
if (organizationId != null) "organizationId": organizationId,
}; };
return _getRequest(ApiEndpoints.getAttendanceLogs, queryParams: query).then( return _getRequest(ApiEndpoints.getAttendanceLogs, queryParams: query).then(
(res) => (res) =>
res != null ? _parseResponse(res, label: 'Attendance Logs') : null); res != null ? _parseResponse(res, label: 'Attendance Logs') : null);
@ -1759,13 +2019,6 @@ class ApiService {
_getRequest("${ApiEndpoints.getAttendanceLogView}/$id").then((res) => _getRequest("${ApiEndpoints.getAttendanceLogView}/$id").then((res) =>
res != null ? _parseResponse(res, label: 'Log Details') : null); res != null ? _parseResponse(res, label: 'Log Details') : null);
static Future<List<dynamic>?> getRegularizationLogs(String projectId) async =>
_getRequest(ApiEndpoints.getRegularizationLogs,
queryParams: {"projectId": projectId})
.then((res) => res != null
? _parseResponse(res, label: 'Regularization Logs')
: null);
static Future<bool> uploadAttendanceImage( static Future<bool> uploadAttendanceImage(
String id, String id,
String employeeId, String employeeId,
@ -1859,11 +2112,15 @@ class ApiService {
return null; return null;
} }
static Future<List<dynamic>?> getAllEmployeesByProject( static Future<List<dynamic>?> getAllEmployeesByProject(String projectId,
String projectId) async { {String? organizationId}) async {
if (projectId.isEmpty) throw ArgumentError('projectId must not be empty'); if (projectId.isEmpty) throw ArgumentError('projectId must not be empty');
final endpoint = "${ApiEndpoints.getAllEmployeesByProject}/$projectId"; // Build the endpoint with optional organizationId query
var endpoint = "${ApiEndpoints.getAllEmployeesByProject}/$projectId";
if (organizationId != null && organizationId.isNotEmpty) {
endpoint += "?organizationId=$organizationId";
}
return _getRequest(endpoint).then( return _getRequest(endpoint).then(
(res) => res != null (res) => res != null
@ -1872,28 +2129,78 @@ class ApiService {
); );
} }
static Future<List<dynamic>?> getAllEmployees() async => /// Fetches employees by projectId, serviceId, and organizationId
_getRequest(ApiEndpoints.getAllEmployees).then((res) => static Future<List<dynamic>?> getEmployeesByProjectService(
res != null ? _parseResponse(res, label: 'All Employees') : null); String projectId, {
String? serviceId,
String? organizationId,
}) async {
if (projectId.isEmpty) {
throw ArgumentError('projectId must not be empty');
}
// Construct query parameters only if non-empty
final queryParams = <String, String>{};
if (serviceId != null && serviceId.isNotEmpty) {
queryParams['serviceId'] = serviceId;
}
if (organizationId != null && organizationId.isNotEmpty) {
queryParams['organizationId'] = organizationId;
}
final endpoint = "${ApiEndpoints.getAllEmployeesByOrganization}/$projectId";
final response = await _getRequest(endpoint, queryParams: queryParams);
if (response != null) {
return _parseResponse(response, label: 'Employees by Project Service');
} else {
return null;
}
}
static Future<List<dynamic>?> getAllEmployees(
{String? organizationId}) async {
var endpoint = ApiEndpoints.getAllEmployees;
// Add organization filter if provided
if (organizationId != null && organizationId.isNotEmpty) {
endpoint += "?organizationId=$organizationId";
}
return _getRequest(endpoint).then(
(res) => res != null ? _parseResponse(res, label: 'All Employees') : null,
);
}
static Future<List<dynamic>?> getRoles() async => static Future<List<dynamic>?> getRoles() async =>
_getRequest(ApiEndpoints.getRoles).then( _getRequest(ApiEndpoints.getRoles).then(
(res) => res != null ? _parseResponse(res, label: 'Roles') : null); (res) => res != null ? _parseResponse(res, label: 'Roles') : null);
static Future<Map<String, dynamic>?> createEmployee({ static Future<Map<String, dynamic>?> createEmployee({
String? id,
required String firstName, required String firstName,
required String lastName, required String lastName,
required String phoneNumber, required String phoneNumber,
required String gender, required String gender,
required String jobRoleId, required String jobRoleId,
required String joiningDate, required String joiningDate,
String? email,
String? organizationId,
bool? hasApplicationAccess,
}) async { }) async {
final body = { final body = {
if (id != null) "id": id,
"firstName": firstName, "firstName": firstName,
"lastName": lastName, "lastName": lastName,
"phoneNumber": phoneNumber, "phoneNumber": phoneNumber,
"gender": gender, "gender": gender,
"jobRoleId": jobRoleId, "jobRoleId": jobRoleId,
"joiningDate": joiningDate "joiningDate": joiningDate,
if (email != null && email.isNotEmpty) "email": email,
if (organizationId != null && organizationId.isNotEmpty)
"organizationId": organizationId,
if (hasApplicationAccess != null)
"hasApplicationAccess": hasApplicationAccess,
}; };
final response = await _postRequest( final response = await _postRequest(
@ -1907,7 +2214,7 @@ class ApiService {
final json = jsonDecode(response.body); final json = jsonDecode(response.body);
return { return {
"success": response.statusCode == 200 && json['success'] == true, "success": response.statusCode == 200 && json['success'] == true,
"data": json "data": json,
}; };
} }
@ -1922,21 +2229,66 @@ class ApiService {
} }
// === Daily Task APIs === // === Daily Task APIs ===
/// Get Daily Task Project Report Filter
static Future<DailyProgressReportFilterResponse?> getDailyTaskFilter(
String projectId) async {
final endpoint =
"${ApiEndpoints.getDailyTaskProjectProgressFilter}/$projectId";
logSafe("Fetching daily task Progress filter for projectId: $projectId");
static Future<List<dynamic>?> getDailyTasks( try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Daily task filter request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(response,
label: "Daily Task Progress Filter");
if (jsonResponse != null) {
return DailyProgressReportFilterResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getDailyTask Progress Filter: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
static Future<List<TaskModel>?> getDailyTasks(
String projectId, { String projectId, {
DateTime? dateFrom, Map<String, dynamic>? filter,
DateTime? dateTo, int pageNumber = 1,
int pageSize = 20,
}) async { }) async {
// Build query parameters
final query = { final query = {
"projectId": projectId, "projectId": projectId,
if (dateFrom != null) "pageNumber": pageNumber.toString(),
"dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom), "pageSize": pageSize.toString(),
if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo), if (filter != null) "filter": jsonEncode(filter),
}; };
return _getRequest(ApiEndpoints.getDailyTask, queryParams: query).then(
(res) => final uri =
res != null ? _parseResponse(res, label: 'Daily Tasks') : null); Uri.parse(ApiEndpoints.getDailyTask).replace(queryParameters: query);
final response = await _getRequest(uri.toString());
final parsed = response != null
? _parseResponse(response, label: 'Daily Tasks')
: null;
if (parsed != null && parsed['data'] != null) {
return (parsed['data'] as List)
.map((e) => TaskModel.fromJson(e))
.toList();
}
return null;
} }
static Future<bool> reportTask({ static Future<bool> reportTask({
@ -1989,14 +2341,44 @@ class ApiService {
return response.statusCode == 200 && json['success'] == true; return response.statusCode == 200 && json['success'] == true;
} }
static Future<Map<String, dynamic>?> getDailyTasksDetails( /// Fetch infra details for a project, optionally filtered by service
String projectId) async { static Future<Map<String, dynamic>?> getInfraDetails(String projectId,
final url = "${ApiEndpoints.dailyTaskDetails}/$projectId"; {String? serviceId}) async {
final response = await _getRequest(url); String endpoint = "/project/infra-details/$projectId";
return response != null
? _parseResponseForAllData(response, label: 'Daily Task Details') if (serviceId != null && serviceId.isNotEmpty) {
as Map<String, dynamic>? endpoint += "?serviceId=$serviceId";
: null; }
final res = await _getRequest(endpoint);
if (res == null) {
logSafe('Infra Details API returned null');
return null;
}
logSafe('Infra Details raw response: ${res.body}');
return _parseResponseForAllData(res, label: 'Infra Details')
as Map<String, dynamic>?;
}
/// Fetch work items for a given work area, optionally filtered by service
static Future<Map<String, dynamic>?> getWorkItemsByWorkArea(String workAreaId,
{String? serviceId}) async {
String endpoint = "/project/tasks/$workAreaId";
if (serviceId != null && serviceId.isNotEmpty) {
endpoint += "?serviceId=$serviceId";
}
final res = await _getRequest(endpoint);
if (res == null) {
logSafe('Work Items API returned null');
return null;
}
logSafe('Work Items raw response: ${res.body}');
return _parseResponseForAllData(res, label: 'Work Items')
as Map<String, dynamic>?;
} }
static Future<bool> assignDailyTask({ static Future<bool> assignDailyTask({
@ -2005,12 +2387,16 @@ class ApiService {
required String description, required String description,
required List<String> taskTeam, required List<String> taskTeam,
DateTime? assignmentDate, DateTime? assignmentDate,
String? organizationId,
String? serviceId,
}) async { }) async {
final body = { final body = {
"workItemId": workItemId, "workItemId": workItemId,
"plannedTask": plannedTask, "plannedTask": plannedTask,
"description": description, "description": description,
"taskTeam": taskTeam, "taskTeam": taskTeam,
"organizationId": organizationId,
"serviceId": serviceId,
"assignmentDate": "assignmentDate":
(assignmentDate ?? DateTime.now()).toUtc().toIso8601String(), (assignmentDate ?? DateTime.now()).toUtc().toIso8601String(),
}; };

View File

@ -1,15 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:url_strategy/url_strategy.dart'; import 'package:url_strategy/url_strategy.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart'; import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart';
import 'package:marco/helpers/services/device_info_service.dart'; import 'package:marco/helpers/services/device_info_service.dart';
import 'package:marco/helpers/theme/theme_customizer.dart'; import 'package:marco/helpers/theme/theme_customizer.dart';
import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/theme/app_theme.dart';
@ -25,10 +20,9 @@ Future<void> initializeApp() async {
]); ]);
await _setupDeviceInfo(); await _setupDeviceInfo();
await _handleAuthTokens(); await _handleAuthTokens();
await _setupTheme(); await _setupTheme();
await _setupControllers(); await _setupFirebaseMessaging();
await _setupFirebaseMessaging();
_finalizeAppStyle(); _finalizeAppStyle();
@ -44,19 +38,25 @@ Future<void> initializeApp() async {
} }
} }
Future<void> _handleAuthTokens() async {
final refreshToken = await LocalStorage.getRefreshToken();
if (refreshToken?.isNotEmpty ?? false) {
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
final success = await AuthService.refreshToken();
if (!success) {
logSafe("⚠️ Refresh token invalid or expired. User must login again.");
}
} else {
logSafe("❌ No refresh token found. Skipping refresh.");
}
}
Future<void> _setupUI() async { Future<void> _setupUI() async {
setPathUrlStrategy(); setPathUrlStrategy();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( logSafe("💡 UI setup completed with default system behavior.");
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarIconBrightness: Brightness.dark,
));
logSafe("💡 UI setup completed.");
} }
Future<void> _setupFirebase() async { Future<void> _setupFirebase() async {
await Firebase.initializeApp(); await Firebase.initializeApp();
logSafe("💡 Firebase initialized."); logSafe("💡 Firebase initialized.");
@ -77,56 +77,16 @@ Future<void> _setupDeviceInfo() async {
logSafe("📱 Device Info: ${deviceInfoService.deviceData}"); logSafe("📱 Device Info: ${deviceInfoService.deviceData}");
} }
Future<void> _handleAuthTokens() async {
final refreshToken = await LocalStorage.getRefreshToken();
if (refreshToken?.isNotEmpty ?? false) {
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
final success = await AuthService.refreshToken();
if (!success) {
logSafe(
"⚠️ Refresh token invalid or expired. Skipping controller injection.");
}
} else {
logSafe("❌ No refresh token found. Skipping refresh.");
}
}
Future<void> _setupTheme() async { Future<void> _setupTheme() async {
await ThemeCustomizer.init(); await ThemeCustomizer.init();
logSafe("💡 Theme customizer initialized."); logSafe("💡 Theme customizer initialized.");
} }
Future<void> _setupControllers() async {
final token = LocalStorage.getString('jwt_token');
if (token?.isEmpty ?? true) {
logSafe("⚠️ No valid JWT token found. Skipping controller initialization.");
return;
}
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
logSafe("💡 PermissionController injected.");
}
if (!Get.isRegistered<ProjectController>()) {
Get.put(ProjectController(), permanent: true);
logSafe("💡 ProjectController injected as permanent.");
}
await Future.wait([
Get.find<PermissionController>().loadData(token!),
Get.find<ProjectController>().fetchProjects(),
]);
}
// Commented out Firebase Messaging setup
Future<void> _setupFirebaseMessaging() async { Future<void> _setupFirebaseMessaging() async {
await FirebaseNotificationService().initialize(); await FirebaseNotificationService().initialize();
logSafe("💡 Firebase Messaging initialized."); logSafe("💡 Firebase Messaging initialized.");
} }
void _finalizeAppStyle() { void _finalizeAppStyle() {
AppStyle.init(); AppStyle.init();
logSafe("💡 AppStyle initialized."); logSafe("💡 AppStyle initialized.");

View File

@ -1,9 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:get/get.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/services/api_endpoints.dart'; import 'package:marco/helpers/services/api_endpoints.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
@ -83,7 +79,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/login-mobile", data); final responseData = await _post("/auth/app/login", data);
if (responseData == null) if (responseData == null)
return {"error": "Network error. Please check your connection."}; return {"error": "Network error. Please check your connection."};
@ -98,8 +94,8 @@ class AuthService {
} }
static Future<bool> refreshToken() async { static Future<bool> refreshToken() async {
final accessToken = await LocalStorage.getJwtToken(); final accessToken = LocalStorage.getJwtToken();
final refreshToken = await LocalStorage.getRefreshToken(); final refreshToken = LocalStorage.getRefreshToken();
if ([accessToken, refreshToken].any((t) => t == null || t.isEmpty)) { if ([accessToken, refreshToken].any((t) => t == null || t.isEmpty)) {
logSafe("Missing access or refresh token.", level: LogLevel.warning); logSafe("Missing access or refresh token.", level: LogLevel.warning);
@ -115,7 +111,7 @@ class AuthService {
logSafe("Token refreshed successfully."); logSafe("Token refreshed successfully.");
// 🔹 Retry FCM token registration after token refresh // 🔹 Retry FCM token registration after token refresh
final newFcmToken = await LocalStorage.getFcmToken(); final newFcmToken = LocalStorage.getFcmToken();
if (newFcmToken?.isNotEmpty ?? false) { if (newFcmToken?.isNotEmpty ?? false) {
final success = await registerDeviceToken(newFcmToken!); final success = await registerDeviceToken(newFcmToken!);
logSafe( logSafe(
@ -157,7 +153,7 @@ class AuthService {
}) => }) =>
_wrapErrorHandling( _wrapErrorHandling(
() async { () async {
final token = await LocalStorage.getJwtToken(); final token = LocalStorage.getJwtToken();
return _post( return _post(
"/auth/generate-mpin", "/auth/generate-mpin",
{"employeeId": employeeId, "mpin": mpin}, {"employeeId": employeeId, "mpin": mpin},
@ -290,30 +286,6 @@ class AuthService {
await LocalStorage.setIsMpin(false); await LocalStorage.setIsMpin(false);
await LocalStorage.removeMpinToken(); await LocalStorage.removeMpinToken();
} }
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
logSafe("✅ PermissionController injected after login.");
}
if (!Get.isRegistered<ProjectController>()) {
Get.put(ProjectController(), permanent: true);
logSafe("✅ ProjectController injected after login.");
}
await Get.find<PermissionController>().loadData(data['token']);
await Get.find<ProjectController>().fetchProjects();
// 🔹 Always try to register FCM token after login
final fcmToken = await LocalStorage.getFcmToken();
if (fcmToken?.isNotEmpty ?? false) {
final success = await registerDeviceToken(fcmToken!);
logSafe(
success
? "✅ FCM token registered after login."
: "⚠️ Failed to register FCM token after login.",
level: success ? LogLevel.info : LogLevel.warning);
}
isLoggedIn = true; isLoggedIn = true;
logSafe("✅ Login flow completed and controllers initialized."); logSafe("✅ Login flow completed and controllers initialized.");
} }

View File

@ -19,7 +19,7 @@ class FirebaseNotificationService {
_registerMessageListeners(); _registerMessageListeners();
_registerTokenRefreshListener(); _registerTokenRefreshListener();
// Fetch token on app start (but only register with server if JWT available) // Fetch token on app start (and register with server if JWT available)
await getFcmToken(registerOnServer: true); await getFcmToken(registerOnServer: true);
} }
@ -49,6 +49,7 @@ class FirebaseNotificationService {
FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap); FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
// Background messages
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
} }
@ -111,8 +112,6 @@ class FirebaseNotificationService {
} }
} }
/// Handle tap on notification /// Handle tap on notification
void _handleNotificationTap(RemoteMessage message) { void _handleNotificationTap(RemoteMessage message) {
_logger.i('📌 Notification tapped: ${message.data}'); _logger.i('📌 Notification tapped: ${message.data}');
@ -129,7 +128,9 @@ class FirebaseNotificationService {
} }
} }
/// Background handler (required by Firebase) /// 🔹 Background handler (required by Firebase)
/// Must be a top-level function and annotated for AOT
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
final logger = Logger(); final logger = Logger();
logger logger

View File

@ -10,6 +10,8 @@ import 'package:marco/controller/directory/directory_controller.dart';
import 'package:marco/controller/directory/notes_controller.dart'; import 'package:marco/controller/directory/notes_controller.dart';
import 'package:marco/controller/document/user_document_controller.dart'; import 'package:marco/controller/document/user_document_controller.dart';
import 'package:marco/controller/document/document_details_controller.dart'; import 'package:marco/controller/document/document_details_controller.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
/// Handles incoming FCM notification actions and updates UI/controllers. /// Handles incoming FCM notification actions and updates UI/controllers.
class NotificationActionHandler { class NotificationActionHandler {
@ -45,6 +47,10 @@ class NotificationActionHandler {
break; break;
case 'attendance_updated': case 'attendance_updated':
_handleAttendanceUpdated(data); _handleAttendanceUpdated(data);
_handleDashboardUpdate(data); // refresh dashboard attendance
break;
case 'dashboard_update':
_handleDashboardUpdate(data); // full dashboard refresh
break; break;
default: default:
_logger.w('⚠️ Unknown notification type: $type'); _logger.w('⚠️ Unknown notification type: $type');
@ -59,16 +65,23 @@ class NotificationActionHandler {
case 'Attendance': case 'Attendance':
if (_isAttendanceAction(action)) { if (_isAttendanceAction(action)) {
_handleAttendanceUpdated(data); _handleAttendanceUpdated(data);
_handleDashboardUpdate(data);
} }
break; break;
case 'Team_Modified':
// Call method to handle team modifications and dashboard update
_handleDashboardUpdate(data);
break;
/// 🔹 Tasks /// 🔹 Tasks
case 'Report_Task': case 'Report_Task':
_handleTaskUpdated(data, isComment: false); _handleTaskUpdated(data, isComment: false);
_handleDashboardUpdate(data);
break; break;
case 'Task_Comment': case 'Task_Comment':
_handleTaskUpdated(data, isComment: true); _handleTaskUpdated(data, isComment: true);
_handleDashboardUpdate(data);
break; break;
case 'Task_Modified': case 'Task_Modified':
@ -76,11 +89,13 @@ class NotificationActionHandler {
case 'Floor_Modified': case 'Floor_Modified':
case 'Building_Modified': case 'Building_Modified':
_handleTaskPlanningUpdated(data); _handleTaskPlanningUpdated(data);
_handleDashboardUpdate(data);
break; break;
/// 🔹 Expenses /// 🔹 Expenses
case 'Expenses_Modified': case 'Expenses_Modified':
_handleExpenseUpdated(data); _handleExpenseUpdated(data);
_handleDashboardUpdate(data);
break; break;
/// 🔹 Documents /// 🔹 Documents
@ -198,77 +213,99 @@ class NotificationActionHandler {
/// ---------------------- DOCUMENT HANDLER ---------------------- /// ---------------------- DOCUMENT HANDLER ----------------------
static void _handleDocumentModified(Map<String, dynamic> data) { static void _handleDocumentModified(Map<String, dynamic> data) {
final entityTypeId = data['EntityTypeId']; String entityTypeId;
final entityId = data['EntityId']; String entityId;
String? documentId = data['DocumentId'];
if (entityTypeId == null || entityId == null) { // Determine entity type and ID
_logger.w( if (data['Keyword'] == 'Employee_Document_Modified') {
"⚠️ Document update received without EntityTypeId/EntityId: $data"); entityTypeId = Permissions.employeeEntity;
entityId = data['EmployeeId'] ?? '';
} else if (data['Keyword'] == 'Project_Document_Modified') {
entityTypeId = Permissions.projectEntity;
entityId = data['ProjectId'] ?? '';
} else {
_logger.w("⚠️ Document update received with unknown keyword: $data");
return; return;
} }
// Refresh document list if (entityId.isEmpty) {
_safeControllerUpdate<DocumentController>( _logger.w("⚠️ Document update missing entityId: $data");
onFound: (controller) async { return;
await controller.fetchDocuments( }
entityTypeId: entityTypeId,
entityId: entityId,
reset: true,
);
},
notFoundMessage: '⚠️ DocumentController not found, cannot refresh list.',
successMessage: '✅ DocumentController refreshed from notification.',
);
// Refresh document details (if open and matches) _logger.i(
// Refresh document details (if open and matches) "🔔 Document notification received: keyword=${data['Keyword']}, entityTypeId=$entityTypeId, entityId=$entityId, documentId=$documentId");
final documentId = data['DocumentId'];
if (documentId != null) { // Refresh Document List
if (Get.isRegistered<DocumentController>()) {
_safeControllerUpdate<DocumentController>(
onFound: (controller) async {
await controller.fetchDocuments(
entityTypeId: entityTypeId,
entityId: entityId,
reset: true,
);
},
notFoundMessage:
'⚠️ DocumentController not found, cannot refresh list.',
successMessage: '✅ DocumentController refreshed from notification.',
);
} else {
_logger.w('⚠️ DocumentController not registered, skipping list refresh.');
}
// Refresh Document Details (if open)
if (documentId != null && Get.isRegistered<DocumentDetailsController>()) {
_safeControllerUpdate<DocumentDetailsController>( _safeControllerUpdate<DocumentDetailsController>(
onFound: (controller) async { onFound: (controller) async {
if (controller.documentDetails.value?.data?.id == documentId) { // 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");
}
}, },
notFoundMessage: ' DocumentDetailsController not active, skipping.', notFoundMessage:
' DocumentDetailsController not active, skipping details refresh.',
successMessage: '✅ DocumentDetailsController checked for refresh.', successMessage: '✅ DocumentDetailsController checked for refresh.',
); );
} else if (documentId != null) {
_logger.w(
'⚠️ DocumentDetailsController not registered, cannot refresh document details.');
} }
} }
/// ---------------------- DIRECTORY HANDLERS ---------------------- /// ---------------------- DIRECTORY HANDLERS ----------------------
static void _handleContactModified(Map<String, dynamic> data) { static void _handleContactModified(Map<String, dynamic> data) {
_safeControllerUpdate<DirectoryController>( final contactId = data['ContactId'];
onFound: (controller) => controller.fetchContacts(),
notFoundMessage: '⚠️ DirectoryController not found, cannot refresh.',
successMessage: '✅ Directory contacts refreshed from notification.',
);
}
static void _handleContactNoteModified(Map<String, dynamic> data) {
final contactId = data['contactId'];
// Always refresh the contact list
_safeControllerUpdate<DirectoryController>( _safeControllerUpdate<DirectoryController>(
onFound: (controller) { onFound: (controller) {
controller.fetchContacts();
// If a specific contact is provided, refresh its notes as well
if (contactId != null) { if (contactId != null) {
controller.fetchCommentsForContact(contactId); controller.fetchCommentsForContact(contactId);
} }
}, },
notFoundMessage: notFoundMessage:
'⚠️ DirectoryController not found, cannot refresh notes.', '⚠️ DirectoryController not found, cannot refresh contacts.',
successMessage: '✅ Directory comments refreshed from notification.', successMessage:
'✅ 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.', notFoundMessage: '⚠️ NotesController not found, cannot refresh notes.',
successMessage: '✅ Notes refreshed from notification.', successMessage: '✅ Notes refreshed from notification.',
); );
} }
static void _handleContactNoteModified(Map<String, dynamic> data) {
// Refresh both contacts and notes when a note is modified
_handleContactModified(data);
}
static void _handleBucketModified(Map<String, dynamic> data) { static void _handleBucketModified(Map<String, dynamic> data) {
_safeControllerUpdate<DirectoryController>( _safeControllerUpdate<DirectoryController>(
onFound: (controller) => controller.fetchBuckets(), onFound: (controller) => controller.fetchBuckets(),
@ -285,6 +322,57 @@ class NotificationActionHandler {
); );
} }
/// ---------------------- DASHBOARD HANDLER ----------------------
static void _handleDashboardUpdate(Map<String, dynamic> data) {
_safeControllerUpdate<DashboardController>(
onFound: (controller) async {
final type = data['type'] ?? '';
switch (type) {
case 'attendance_updated':
await controller.fetchRoleWiseAttendance();
break;
case 'task_updated':
await controller.fetchDashboardTasks(
projectId: controller.projectController.selectedProjectId.value,
);
break;
case 'project_progress_update':
await controller.fetchProjectProgress();
break;
case 'Employee_Suspend':
final currentProjectId =
controller.projectController.selectedProjectId.value;
final projectIdsString = data['ProjectIds'] ?? '';
// Convert comma-separated string to List<String>
final notificationProjectIds =
projectIdsString.split(',').map((e) => e.trim()).toList();
// Refresh only if current project ID is in the list
if (notificationProjectIds.contains(currentProjectId)) {
await controller.fetchDashboardTeams(projectId: currentProjectId);
}
break;
case 'Team_Modified':
final projectId = data['ProjectId'] ??
controller.projectController.selectedProjectId.value;
await controller.fetchDashboardTeams(projectId: projectId);
break;
case 'full_dashboard_refresh':
default:
await controller.refreshDashboard();
}
},
notFoundMessage: '⚠️ DashboardController not found, cannot refresh.',
successMessage: '✅ DashboardController refreshed from notification.',
);
}
/// ---------------------- UTILITY ---------------------- /// ---------------------- UTILITY ----------------------
static void _safeControllerUpdate<T>({ static void _safeControllerUpdate<T>({

View File

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

View File

@ -22,6 +22,17 @@ class LocalStorage {
static const String _isMpinKey = "isMpin"; static const String _isMpinKey = "isMpin";
static const String _fcmTokenKey = "fcm_token"; static const String _fcmTokenKey = "fcm_token";
static const String _menuStorageKey = "dynamic_menus"; static const String _menuStorageKey = "dynamic_menus";
// In LocalStorage
static const String _recentTenantKey = "recent_tenant_id";
static Future<bool> setRecentTenantId(String tenantId) =>
preferences.setString(_recentTenantKey, tenantId);
static String? getRecentTenantId() =>
_initialized ? preferences.getString(_recentTenantKey) : null;
static Future<bool> removeRecentTenantId() =>
preferences.remove(_recentTenantKey);
static SharedPreferences? _preferencesInstance; static SharedPreferences? _preferencesInstance;
static bool _initialized = false; static bool _initialized = false;
@ -76,7 +87,8 @@ class LocalStorage {
static Future<bool> removeMenus() => preferences.remove(_menuStorageKey); static Future<bool> removeMenus() => preferences.remove(_menuStorageKey);
// ================== User Permissions ================== // ================== User Permissions ==================
static Future<bool> setUserPermissions(List<UserPermission> permissions) async { static Future<bool> setUserPermissions(
List<UserPermission> permissions) async {
final jsonList = permissions.map((e) => e.toJson()).toList(); final jsonList = permissions.map((e) => e.toJson()).toList();
return preferences.setString(_userPermissionsKey, jsonEncode(jsonList)); return preferences.setString(_userPermissionsKey, jsonEncode(jsonList));
} }
@ -94,8 +106,8 @@ class LocalStorage {
preferences.remove(_userPermissionsKey); preferences.remove(_userPermissionsKey);
// ================== Employee Info ================== // ================== Employee Info ==================
static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) => static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) => preferences
preferences.setString(_employeeInfoKey, jsonEncode(employeeInfo.toJson())); .setString(_employeeInfoKey, jsonEncode(employeeInfo.toJson()));
static EmployeeInfo? getEmployeeInfo() { static EmployeeInfo? getEmployeeInfo() {
if (!_initialized) return null; if (!_initialized) return null;
@ -135,6 +147,7 @@ class LocalStorage {
await removeMpinToken(); await removeMpinToken();
await removeIsMpin(); await removeIsMpin();
await removeMenus(); await removeMenus();
await removeRecentTenantId();
await preferences.remove("mpin_verified"); await preferences.remove("mpin_verified");
await preferences.remove(_languageKey); await preferences.remove(_languageKey);
await preferences.remove(_themeCustomizerKey); await preferences.remove(_themeCustomizerKey);

View File

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

View File

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

View File

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

View File

@ -4,7 +4,6 @@ import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.dart'; import 'package:syncfusion_flutter_charts/charts.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';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
class AttendanceDashboardChart extends StatelessWidget { class AttendanceDashboardChart extends StatelessWidget {
AttendanceDashboardChart({Key? key}) : super(key: key); AttendanceDashboardChart({Key? key}) : super(key: key);
@ -46,13 +45,9 @@ class AttendanceDashboardChart extends StatelessWidget {
Color(0xFF64B5F6), // Blue 300 (repeat) Color(0xFF64B5F6), // Blue 300 (repeat)
]; ];
static final Map<String, Color> _roleColorMap = {};
Color _getRoleColor(String role) { Color _getRoleColor(String role) {
return _roleColorMap.putIfAbsent( final index = role.hashCode.abs() % _flatColors.length;
role, return _flatColors[index];
() => _flatColors[_roleColorMap.length % _flatColors.length],
);
} }
@override @override
@ -62,12 +57,8 @@ class AttendanceDashboardChart extends StatelessWidget {
return Obx(() { return Obx(() {
final isChartView = _controller.attendanceIsChartView.value; final isChartView = _controller.attendanceIsChartView.value;
final selectedRange = _controller.attendanceSelectedRange.value; final selectedRange = _controller.attendanceSelectedRange.value;
final isLoading = _controller.isAttendanceLoading.value;
final filteredData = _getFilteredData(); final filteredData = _getFilteredData();
if (isLoading) {
return SkeletonLoaders.buildLoadingSkeleton();
}
return Container( return Container(
decoration: _containerDecoration, decoration: _containerDecoration,
@ -106,7 +97,7 @@ class AttendanceDashboardChart extends StatelessWidget {
BoxDecoration get _containerDecoration => BoxDecoration( BoxDecoration get _containerDecoration => BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(5),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.grey.withOpacity(0.05), color: Colors.grey.withOpacity(0.05),
@ -164,7 +155,7 @@ class _Header extends StatelessWidget {
), ),
), ),
ToggleButtons( ToggleButtons(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
borderColor: Colors.grey, borderColor: Colors.grey,
fillColor: Colors.blueAccent.withOpacity(0.15), fillColor: Colors.blueAccent.withOpacity(0.15),
selectedBorderColor: Colors.blueAccent, selectedBorderColor: Colors.blueAccent,
@ -208,7 +199,7 @@ class _Header extends StatelessWidget {
: FontWeight.normal, : FontWeight.normal,
), ),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
side: BorderSide( side: BorderSide(
color: selectedRange == label color: selectedRange == label
? Colors.blueAccent ? Colors.blueAccent
@ -262,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()
@ -281,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(8),
),
child: const Center( child: const Center(
child: Text( child: Text(
'No attendance data for the selected range.', 'No attendance data for the selected range.',
@ -310,8 +297,7 @@ 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(8),
), ),
child: SfCartesianChart( child: SfCartesianChart(
tooltipBehavior: TooltipBehavior(enable: true, shared: true), tooltipBehavior: TooltipBehavior(enable: true, shared: true),
@ -325,7 +311,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,
@ -366,7 +352,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()
@ -385,10 +371,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(12),
),
child: const Center( child: const Center(
child: Text( child: Text(
'No attendance data for the selected range.', 'No attendance data for the selected range.',
@ -409,39 +391,50 @@ class _AttendanceTable extends StatelessWidget {
height: 300, height: 300,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12), 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(), ),
), ),
), ),
); );
@ -461,7 +454,7 @@ class _RolePill extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withOpacity(0.15), color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
child: MyText.labelSmall(role, fontWeight: 500), child: MyText.labelSmall(role, fontWeight: 500),
); );

View File

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

View File

@ -5,7 +5,6 @@ import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:marco/model/dashboard/project_progress_model.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';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
class ProjectProgressChart extends StatelessWidget { class ProjectProgressChart extends StatelessWidget {
final List<ChartTaskData> data; final List<ChartTaskData> data;
@ -50,13 +49,9 @@ class ProjectProgressChart extends StatelessWidget {
]; ];
static final NumberFormat _commaFormatter = NumberFormat.decimalPattern(); static final NumberFormat _commaFormatter = NumberFormat.decimalPattern();
static final Map<String, Color> _taskColorMap = {};
Color _getTaskColor(String taskName) { Color _getTaskColor(String taskName) {
return _taskColorMap.putIfAbsent( final index = taskName.hashCode % _flatColors.length;
taskName, return _flatColors[index];
() => _flatColors[_taskColorMap.length % _flatColors.length],
);
} }
@override @override
@ -66,12 +61,11 @@ class ProjectProgressChart extends StatelessWidget {
return Obx(() { return Obx(() {
final isChartView = controller.projectIsChartView.value; final isChartView = controller.projectIsChartView.value;
final selectedRange = controller.projectSelectedRange.value; final selectedRange = controller.projectSelectedRange.value;
final isLoading = controller.isProjectLoading.value;
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(5),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.grey.withOpacity(0.04), color: Colors.grey.withOpacity(0.04),
@ -94,13 +88,11 @@ class ProjectProgressChart extends StatelessWidget {
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) => AnimatedSwitcher( builder: (context, constraints) => AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: isLoading child: data.isEmpty
? SkeletonLoaders.buildLoadingSkeleton() ? _buildNoDataMessage()
: data.isEmpty : isChartView
? _buildNoDataMessage() ? _buildChart(constraints.maxHeight)
: isChartView : _buildTable(constraints.maxHeight, screenWidth),
? _buildChart(constraints.maxHeight)
: _buildTable(constraints.maxHeight, screenWidth),
), ),
), ),
), ),
@ -129,7 +121,7 @@ class ProjectProgressChart extends StatelessWidget {
), ),
), ),
ToggleButtons( ToggleButtons(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
borderColor: Colors.grey, borderColor: Colors.grey,
fillColor: Colors.blueAccent.withOpacity(0.15), fillColor: Colors.blueAccent.withOpacity(0.15),
selectedBorderColor: Colors.blueAccent, selectedBorderColor: Colors.blueAccent,
@ -182,7 +174,7 @@ class ProjectProgressChart extends StatelessWidget {
selectedRange == label ? FontWeight.w600 : FontWeight.normal, selectedRange == label ? FontWeight.w600 : FontWeight.normal,
), ),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
side: BorderSide( side: BorderSide(
color: selectedRange == label color: selectedRange == label
? Colors.blueAccent ? Colors.blueAccent
@ -205,13 +197,13 @@ class ProjectProgressChart extends StatelessWidget {
height: height > 280 ? 280 : height, height: height > 280 ? 280 : height,
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blueGrey.shade50, // Remove background
borderRadius: BorderRadius.circular(8), color: Colors.transparent,
borderRadius: BorderRadius.circular(5),
), ),
child: SfCartesianChart( child: SfCartesianChart(
tooltipBehavior: TooltipBehavior(enable: true), tooltipBehavior: TooltipBehavior(enable: true),
legend: Legend(isVisible: true, position: LegendPosition.bottom), legend: Legend(isVisible: true, position: LegendPosition.bottom),
// Use CategoryAxis so only nonZeroData dates show up
primaryXAxis: CategoryAxis( primaryXAxis: CategoryAxis(
majorGridLines: const MajorGridLines(width: 0), majorGridLines: const MajorGridLines(width: 0),
axisLine: const AxisLine(width: 0), axisLine: const AxisLine(width: 0),
@ -280,49 +272,45 @@ class ProjectProgressChart extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
color: Colors.grey.shade50, color: Colors.transparent,
), ),
child: LayoutBuilder( child: Scrollbar(
builder: (context, constraints) { thumbVisibility: true,
return SingleChildScrollView( trackVisibility: true,
scrollDirection: Axis.horizontal, child: SingleChildScrollView(
child: ConstrainedBox( scrollDirection: Axis.horizontal,
constraints: BoxConstraints(minWidth: constraints.maxWidth), child: ConstrainedBox(
child: SingleChildScrollView( constraints: BoxConstraints(minWidth: screenWidth),
scrollDirection: Axis.vertical, child: SingleChildScrollView(
child: DataTable( scrollDirection: Axis.vertical,
columnSpacing: screenWidth < 600 ? 16 : 36, child: DataTable(
headingRowHeight: 44, columnSpacing: screenWidth < 600 ? 16 : 36,
headingRowColor: MaterialStateProperty.all( headingRowHeight: 44,
Colors.blueAccent.withOpacity(0.08)), headingRowColor: MaterialStateProperty.all(
headingTextStyle: const TextStyle( Colors.blueAccent.withOpacity(0.08)),
fontWeight: FontWeight.bold, color: Colors.black87), headingTextStyle: const TextStyle(
columns: const [ fontWeight: FontWeight.bold, color: Colors.black87),
DataColumn(label: Text('Date')), columns: const [
DataColumn(label: Text('Planned')), DataColumn(label: Text('Date')),
DataColumn(label: Text('Completed')), DataColumn(label: Text('Planned')),
], DataColumn(label: Text('Completed')),
rows: nonZeroData.map((task) { ],
return DataRow( rows: nonZeroData.map((task) {
cells: [ return DataRow(
DataCell(Text(DateFormat('d MMM').format(task.date))), cells: [
DataCell(Text( DataCell(Text(DateFormat('d MMM').format(task.date))),
'${task.planned}', DataCell(Text('${task.planned}',
style: TextStyle(color: _getTaskColor('Planned')), style: TextStyle(color: _getTaskColor('Planned')))),
)), DataCell(Text('${task.completed}',
DataCell(Text( style: TextStyle(color: _getTaskColor('Completed')))),
'${task.completed}', ],
style: TextStyle(color: _getTaskColor('Completed')), );
)), }).toList(),
],
);
}).toList(),
),
), ),
), ),
); ),
}, ),
), ),
); );
} }
@ -331,8 +319,8 @@ class ProjectProgressChart extends StatelessWidget {
return Container( return Container(
height: height > 280 ? 280 : height, height: height > 280 ? 280 : height,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blueGrey.shade50, color: Colors.transparent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(5),
), ),
child: const Center( child: const Center(
child: Text( child: Text(

View File

@ -154,8 +154,10 @@ class TileContainer extends StatelessWidget {
const TileContainer({required this.child, Key? key}) : super(key: key); const TileContainer({required this.child, Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context) => Widget build(BuildContext context) => Container(
Container(padding: const EdgeInsets.all(14), decoration: _tileDecoration, child: child); padding: const EdgeInsets.all(14),
decoration: _tileDecoration,
child: child);
} }
/// ========================== /// ==========================
@ -187,9 +189,8 @@ class AttachmentsSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
final activeExisting = existingAttachments final activeExisting =
.where((doc) => doc['isActive'] != false) existingAttachments.where((doc) => doc['isActive'] != false).toList();
.toList();
final imageFiles = attachments.where(_isImageFile).toList(); final imageFiles = attachments.where(_isImageFile).toList();
final imageExisting = activeExisting final imageExisting = activeExisting
@ -336,8 +337,8 @@ class AttachmentsSection extends StatelessWidget {
Widget _buildActionTile(IconData icon, VoidCallback onTap) => GestureDetector( Widget _buildActionTile(IconData icon, VoidCallback onTap) => GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
width: 80, width: 50,
height: 80, height: 50,
decoration: _tileDecoration.copyWith( decoration: _tileDecoration.copyWith(
border: Border.all(color: Colors.grey.shade400), border: Border.all(color: Colors.grey.shade400),
), ),
@ -359,7 +360,8 @@ class _AttachmentTile extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final fileName = file.path.split('/').last; final fileName = file.path.split('/').last;
final extension = fileName.split('.').last.toLowerCase(); final extension = fileName.split('.').last.toLowerCase();
final isImage = AttachmentsSection.allowedImageExtensions.contains(extension); final isImage =
AttachmentsSection.allowedImageExtensions.contains(extension);
final (icon, color) = _fileIcon(extension); final (icon, color) = _fileIcon(extension);

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class ConfirmDialog extends StatelessWidget { class ConfirmDialog extends StatelessWidget {
final String title; final String title;
@ -115,7 +115,11 @@ class _ContentView extends StatelessWidget {
Navigator.pop(context, true); // close on success Navigator.pop(context, true); // close on success
} catch (e) { } catch (e) {
// Show error, dialog stays open // Show error, dialog stays open
Get.snackbar("Error", "Failed to delete. Try again."); showAppSnackbar(
title: "Error",
message: "Failed to delete. Try again.",
type: SnackbarType.error,
);
} finally { } finally {
loading.value = false; loading.value = false;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -108,8 +108,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 +169,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);
} }
@ -193,7 +197,7 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
controller.uploadingStates[uniqueLogKey]?.value = false; controller.uploadingStates[uniqueLogKey]?.value = false;
if (success) { if (success) {
await controller.fetchEmployeesByProject(selectedProjectId); await controller.fetchTodaysAttendance(selectedProjectId);
await controller.fetchAttendanceLogs(selectedProjectId); await controller.fetchAttendanceLogs(selectedProjectId);
await controller.fetchRegularizationLogs(selectedProjectId); await controller.fetchRegularizationLogs(selectedProjectId);
await controller.fetchProjectData(selectedProjectId); await controller.fetchProjectData(selectedProjectId);
@ -205,13 +209,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,
@ -272,12 +280,12 @@ class AttendanceActionButtonUI extends StatelessWidget {
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 +296,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 +351,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 +385,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

@ -1,10 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/attendance/attendance_screen_controller.dart'; 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';
class AttendanceFilterBottomSheet extends StatefulWidget { class AttendanceFilterBottomSheet extends StatefulWidget {
final AttendanceController controller; final AttendanceController controller;
@ -36,14 +37,79 @@ class _AttendanceFilterBottomSheetState
String getLabelText() { String getLabelText() {
final startDate = widget.controller.startDateAttendance; final startDate = widget.controller.startDateAttendance;
final endDate = widget.controller.endDateAttendance; final endDate = widget.controller.endDateAttendance;
if (startDate != null && endDate != null) { if (startDate != null && endDate != null) {
final start = DateFormat('dd/MM/yyyy').format(startDate); final start = DateTimeUtils.formatDate(startDate, 'dd MMM yyyy');
final end = DateFormat('dd/MM/yyyy').format(endDate); final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy');
return "$start - $end"; return "$start - $end";
} }
return "Date Range"; return "Date Range";
} }
Widget _popupSelector({
required String currentValue,
required List<String> items,
required ValueChanged<String> onSelected,
}) {
return PopupMenuButton<String>(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: onSelected,
itemBuilder: (context) => items
.map((e) => PopupMenuItem<String>(
value: e,
child: MyText(e),
))
.toList(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: MyText(
currentValue,
style: const TextStyle(color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
);
}
Widget _buildOrganizationSelector(BuildContext context) {
final orgNames = [
"All Organizations",
...widget.controller.organizations.map((e) => e.name)
];
return _popupSelector(
currentValue:
widget.controller.selectedOrganization?.name ?? "All Organizations",
items: orgNames,
onSelected: (name) {
if (name == "All Organizations") {
setState(() {
widget.controller.selectedOrganization = null;
});
} else {
final selectedOrg = widget.controller.organizations
.firstWhere((org) => org.name == name);
setState(() {
widget.controller.selectedOrganization = selectedOrg;
});
}
},
);
}
List<Widget> buildMainFilters() { List<Widget> buildMainFilters() {
final hasRegularizationPermission = widget.permissionController final hasRegularizationPermission = widget.permissionController
.hasPermission(Permissions.regularizeAttendance); .hasPermission(Permissions.regularizeAttendance);
@ -61,7 +127,7 @@ class _AttendanceFilterBottomSheetState
final List<Widget> widgets = [ final List<Widget> widgets = [
Padding( Padding(
padding: EdgeInsets.only(bottom: 4), padding: const EdgeInsets.only(bottom: 4),
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: MyText.titleSmall("View", fontWeight: 600), child: MyText.titleSmall("View", fontWeight: 600),
@ -82,11 +148,66 @@ class _AttendanceFilterBottomSheetState
}), }),
]; ];
// 🔹 Organization filter
widgets.addAll([
const Divider(),
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 12),
child: Align(
alignment: Alignment.centerLeft,
child: MyText.titleSmall("Choose Organization", fontWeight: 600),
),
),
Obx(() {
if (widget.controller.isLoadingOrganizations.value) {
return Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 100,
height: 14,
color: Colors.grey.shade400,
),
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: Colors.grey.shade400,
shape: BoxShape.circle,
),
),
],
),
);
} else if (widget.controller.organizations.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: MyText.bodyMedium(
"No organizations found",
fontWeight: 500,
color: Colors.grey,
),
),
);
}
return _buildOrganizationSelector(context);
}),
]);
// 🔹 Date Range only for attendanceLogs
if (tempSelectedTab == 'attendanceLogs') { if (tempSelectedTab == 'attendanceLogs') {
widgets.addAll([ widgets.addAll([
const Divider(), const Divider(),
Padding( Padding(
padding: EdgeInsets.only(top: 12, bottom: 4), padding: const EdgeInsets.only(top: 12, bottom: 4),
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: MyText.titleSmall("Date Range", fontWeight: 600), child: MyText.titleSmall("Date Range", fontWeight: 600),
@ -99,7 +220,7 @@ class _AttendanceFilterBottomSheetState
context, context,
widget.controller, widget.controller,
); );
setState(() {}); // rebuild UI after date range is updated setState(() {});
}, },
child: Ink( child: Ink(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -136,9 +257,11 @@ class _AttendanceFilterBottomSheetState
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: BaseBottomSheet( child: BaseBottomSheet(
title: "Attendance Filter", title: "Attendance Filter",
submitText: "Apply",
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,18 +1,25 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/attendance_actions.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';
class AttendanceLogViewButton extends StatelessWidget { class AttendanceLogViewButton extends StatefulWidget {
final dynamic employee; final dynamic employee;
final dynamic attendanceController; final dynamic attendanceController;
const AttendanceLogViewButton({ const AttendanceLogViewButton({
Key? key, Key? key,
required this.employee, required this.employee,
required this.attendanceController, required this.attendanceController,
}) : super(key: key); }) : super(key: key);
@override
State<AttendanceLogViewButton> createState() =>
_AttendanceLogViewButtonState();
}
class _AttendanceLogViewButtonState extends State<AttendanceLogViewButton> {
Future<void> _openGoogleMaps( Future<void> _openGoogleMaps(
BuildContext context, double lat, double lon) async { BuildContext context, double lat, double lon) async {
final url = 'https://www.google.com/maps/search/?api=1&query=$lat,$lon'; final url = 'https://www.google.com/maps/search/?api=1&query=$lat,$lon';
@ -49,7 +56,8 @@ class AttendanceLogViewButton extends StatelessWidget {
} }
void _showLogsBottomSheet(BuildContext context) async { void _showLogsBottomSheet(BuildContext context) async {
await attendanceController.fetchLogsView(employee.id.toString()); await widget.attendanceController
.fetchLogsView(widget.employee.id.toString());
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@ -58,157 +66,238 @@ class AttendanceLogViewButton extends StatelessWidget {
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (context) => BaseBottomSheet( builder: (context) {
title: "Attendance Log", Map<int, bool> expandedDescription = {};
onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context), return BaseBottomSheet(
showButtons: false, title: "Attendance Log",
child: attendanceController.attendenceLogsView.isEmpty onCancel: () => Navigator.pop(context),
? Padding( onSubmit: () => Navigator.pop(context),
padding: const EdgeInsets.symmetric(vertical: 24.0), showButtons: false,
child: Column( child: widget.attendanceController.attendenceLogsView.isEmpty
children: const [ ? Padding(
Icon(Icons.info_outline, size: 40, color: Colors.grey), padding: const EdgeInsets.symmetric(vertical: 24.0),
SizedBox(height: 8), child: Column(
Text("No attendance logs available."), children: [
], Icon(Icons.info_outline, size: 40, color: Colors.grey),
), SizedBox(height: 8),
) MyText.bodySmall("No attendance logs available."),
: ListView.separated( ],
shrinkWrap: true, ),
physics: const NeverScrollableScrollPhysics(), )
itemCount: attendanceController.attendenceLogsView.length, : StatefulBuilder(
separatorBuilder: (_, __) => const SizedBox(height: 16), builder: (context, setStateSB) {
itemBuilder: (_, index) { return ListView.separated(
final log = attendanceController.attendenceLogsView[index]; shrinkWrap: true,
return Container( physics: const NeverScrollableScrollPhysics(),
decoration: BoxDecoration( itemCount:
color: Theme.of(context).colorScheme.surfaceVariant, widget.attendanceController.attendenceLogsView.length,
borderRadius: BorderRadius.circular(12), separatorBuilder: (_, __) => const SizedBox(height: 16),
boxShadow: [ itemBuilder: (_, index) {
BoxShadow( final log = widget
color: Colors.black.withOpacity(0.05), .attendanceController.attendenceLogsView[index];
blurRadius: 6,
offset: const Offset(0, 2), return Container(
) decoration: BoxDecoration(
], color: Theme.of(context).colorScheme.surfaceVariant,
), borderRadius: BorderRadius.circular(12),
padding: const EdgeInsets.all(8), boxShadow: [
child: Column( BoxShadow(
crossAxisAlignment: CrossAxisAlignment.start, color: Colors.black.withOpacity(0.05),
children: [ blurRadius: 6,
Row( offset: const Offset(0, 2),
crossAxisAlignment: CrossAxisAlignment.center, )
children: [ ],
Expanded( ),
flex: 3, padding: const EdgeInsets.all(12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: Icon + Date + Time
Row(
children: [ children: [
Row( _getLogIcon(log),
children: [ const SizedBox(width: 12),
_getLogIcon(log), MyText.bodyLarge(
const SizedBox(width: 10), (log.formattedDate != null &&
Column( log.formattedDate!.isNotEmpty)
crossAxisAlignment: ? DateTimeUtils.convertUtcToLocal(
CrossAxisAlignment.start, log.formattedDate!,
children: [ format: 'd MMM yyyy',
MyText.bodyLarge( )
log.formattedDate ?? '-', : '-',
fontWeight: 600, fontWeight: 600,
),
MyText.bodySmall(
"Time: ${log.formattedTime ?? '-'}",
color: Colors.grey[700],
),
],
),
],
), ),
const SizedBox(height: 12), const SizedBox(width: 12),
Row( MyText.bodySmall(
crossAxisAlignment: log.formattedTime != null
CrossAxisAlignment.start, ? "Time: ${log.formattedTime}"
children: [ : "",
if (log.latitude != null && color: Colors.grey[700],
log.longitude != null)
GestureDetector(
onTap: () {
final lat = double.tryParse(
log.latitude.toString()) ??
0.0;
final lon = double.tryParse(
log.longitude.toString()) ??
0.0;
if (lat >= -90 &&
lat <= 90 &&
lon >= -180 &&
lon <= 180) {
_openGoogleMaps(
context, lat, lon);
} else {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Invalid location coordinates')),
);
}
},
child: const Padding(
padding:
EdgeInsets.only(right: 8.0),
child: Icon(Icons.location_on,
size: 18, color: Colors.blue),
),
),
Expanded(
child: MyText.bodyMedium(
log.comment?.isNotEmpty == true
? log.comment
: "No description provided",
fontWeight: 500,
),
),
],
), ),
], ],
), ),
), const SizedBox(height: 12),
const SizedBox(width: 16), const Divider(height: 1, color: Colors.grey),
if (log.thumbPreSignedUrl != null) // Middle Row: Image + Text (Done by, Description & Location)
GestureDetector( Row(
onTap: () { crossAxisAlignment: CrossAxisAlignment.start,
if (log.preSignedUrl != null) { children: [
_showImageDialog( // Image Column
context, log.preSignedUrl!); if (log.thumbPreSignedUrl != null)
} GestureDetector(
}, onTap: () {
child: ClipRRect( if (log.preSignedUrl != null) {
borderRadius: BorderRadius.circular(8), _showImageDialog(
child: Image.network( context, log.preSignedUrl!);
log.thumbPreSignedUrl!, }
height: 60, },
width: 60, child: ClipRRect(
fit: BoxFit.cover, borderRadius: BorderRadius.circular(8),
errorBuilder: (context, error, stackTrace) { child: Image.network(
return const Icon(Icons.broken_image, log.thumbPreSignedUrl!,
size: 20, color: Colors.grey); height: 60,
}, width: 60,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const Icon(Icons.broken_image,
size: 40, color: Colors.grey),
),
),
),
if (log.thumbPreSignedUrl != null)
const SizedBox(width: 12),
// Text Column
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
// Done by
if (log.updatedByEmployee != null)
MyText.bodySmall(
"By: ${log.updatedByEmployee!.firstName} ${log.updatedByEmployee!.lastName}",
color: Colors.grey[700],
),
const SizedBox(height: 8),
// Location
if (log.latitude != null &&
log.longitude != null)
Row(
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: () {
final lat = double.tryParse(
log.latitude
.toString()) ??
0.0;
final lon = double.tryParse(
log.longitude
.toString()) ??
0.0;
if (lat >= -90 &&
lat <= 90 &&
lon >= -180 &&
lon <= 180) {
_openGoogleMaps(
context, lat, lon);
} else {
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: MyText.bodySmall(
"Invalid location coordinates")),
);
}
},
child: Row(
children: [
Icon(Icons.location_on,
size: 16,
color: Colors.blue),
SizedBox(width: 4),
MyText.bodySmall(
"View Location",
color: Colors.blue,
decoration:
TextDecoration.underline,
),
],
),
),
],
),
const SizedBox(height: 8),
// Description with label and more/less using MyText
if (log.comment != null &&
log.comment!.isNotEmpty)
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
MyText.bodySmall(
"Description: ${log.comment!}",
maxLines: expandedDescription[
index] ==
true
? null
: 2,
overflow: expandedDescription[
index] ==
true
? TextOverflow.visible
: TextOverflow.ellipsis,
),
if (log.comment!.length > 100)
GestureDetector(
onTap: () {
setStateSB(() {
expandedDescription[
index] =
!(expandedDescription[
index] ==
true);
});
},
child: MyText.bodySmall(
expandedDescription[
index] ==
true
? "less"
: "more",
color: Colors.blue,
fontWeight: 600,
),
),
],
)
else
MyText.bodySmall(
"Description: No description provided",
fontWeight: 700,
),
],
),
), ),
), ],
) ),
else ],
const Icon(Icons.broken_image, ),
size: 20, color: Colors.grey), );
], },
), );
], },
), ),
); );
}, },
),
),
); );
} }
@ -219,16 +308,16 @@ class AttendanceLogViewButton extends StatelessWidget {
child: ElevatedButton( child: ElevatedButton(
onPressed: () => _showLogsBottomSheet(context), onPressed: () => _showLogsBottomSheet(context),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AttendanceActionColors.colors[ButtonActions.checkIn], backgroundColor: Colors.indigo,
textStyle: const TextStyle(fontSize: 12), textStyle: const TextStyle(fontSize: 12),
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
), ),
child: const FittedBox( child: FittedBox(
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
child: Text( child: MyText.bodySmall(
"View", "View",
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: Colors.white), color: Colors.white,
), ),
), ),
), ),
@ -249,7 +338,7 @@ class AttendanceLogViewButton extends StatelessWidget {
final today = DateTime(now.year, now.month, now.day); final today = DateTime(now.year, now.month, now.day);
final logDay = DateTime(logDate.year, logDate.month, logDate.day); final logDay = DateTime(logDate.year, logDate.month, logDate.day);
final yesterday = today.subtract(Duration(days: 1)); final yesterday = today.subtract(const Duration(days: 1));
isTodayOrYesterday = (logDay == today) || (logDay == yesterday); isTodayOrYesterday = (logDay == today) || (logDay == yesterday);
} }

View File

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

View File

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

View File

@ -2,10 +2,16 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart'; import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:marco/controller/tenant/organization_selection_controller.dart';
import 'package:marco/controller/tenant/service_controller.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_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/tenant/organization_selector.dart';
import 'package:marco/helpers/widgets/tenant/service_selector.dart';
import 'package:marco/model/attendance/organization_per_project_list_model.dart';
import 'package:marco/model/tenant/tenant_services_model.dart';
class AssignTaskBottomSheet extends StatefulWidget { class AssignTaskBottomSheet extends StatefulWidget {
final String workLocation; final String workLocation;
@ -36,24 +42,46 @@ class AssignTaskBottomSheet extends StatefulWidget {
class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> { class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
final DailyTaskPlanningController controller = Get.find(); final DailyTaskPlanningController controller = Get.find();
final ProjectController projectController = Get.find(); final ProjectController projectController = Get.find();
final OrganizationController orgController = Get.put(OrganizationController());
final ServiceController serviceController = Get.put(ServiceController());
final TextEditingController targetController = TextEditingController(); final TextEditingController targetController = TextEditingController();
final TextEditingController descriptionController = TextEditingController(); final TextEditingController descriptionController = TextEditingController();
final ScrollController _employeeListScrollController = ScrollController(); final ScrollController _employeeListScrollController = ScrollController();
String? selectedProjectId; String? selectedProjectId;
Organization? selectedOrganization;
Service? selectedService;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
selectedProjectId = projectController.selectedProjectId.value; selectedProjectId = projectController.selectedProjectId.value;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) async {
if (selectedProjectId != null) { if (selectedProjectId != null) {
controller.fetchEmployeesByProject(selectedProjectId!); await orgController.fetchOrganizations(selectedProjectId!);
_resetSelections();
await _fetchEmployeesAndTasks();
} }
}); });
} }
void _resetSelections() {
controller.selectedEmployees.clear();
controller.uploadingStates.forEach((key, value) => value.value = false);
}
Future<void> _fetchEmployeesAndTasks() async {
await controller.fetchEmployeesByProjectService(
projectId: selectedProjectId!,
serviceId: selectedService?.id,
organizationId: selectedOrganization?.id,
);
await controller.fetchTaskData(selectedProjectId, serviceId: selectedService?.id);
}
@override @override
void dispose() { void dispose() {
_employeeListScrollController.dispose(); _employeeListScrollController.dispose();
@ -77,12 +105,47 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_infoRow(Icons.location_on, "Work Location", // Organization Selector
"${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"), SizedBox(
Divider(), height: 50,
_infoRow(Icons.pending_actions, "Pending Task of Activity", child: OrganizationSelector(
"${widget.pendingTask}"), controller: orgController,
Divider(), onSelectionChanged: (org) async {
setState(() => selectedOrganization = org);
_resetSelections();
if (selectedProjectId != null) await _fetchEmployeesAndTasks();
},
),
),
MySpacing.height(12),
// Service Selector
SizedBox(
height: 50,
child: ServiceSelector(
controller: serviceController,
onSelectionChanged: (service) async {
setState(() => selectedService = service);
_resetSelections();
if (selectedProjectId != null) await _fetchEmployeesAndTasks();
},
),
),
MySpacing.height(16),
// Work Location Info
_infoRow(
Icons.location_on,
"Work Location",
"${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}",
),
const Divider(),
// Pending Task Info
_infoRow(Icons.pending_actions, "Pending Task", "${widget.pendingTask}"),
const Divider(),
// Role Selector
GestureDetector( GestureDetector(
onTap: _onRoleMenuPressed, onTap: _onRoleMenuPressed,
child: Row( child: Row(
@ -94,21 +157,34 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
), ),
), ),
MySpacing.height(8), MySpacing.height(8),
// Employee List
Container( Container(
constraints: const BoxConstraints(maxHeight: 150), constraints: const BoxConstraints(maxHeight: 180),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(6),
),
child: _buildEmployeeList(), child: _buildEmployeeList(),
), ),
MySpacing.height(8), MySpacing.height(8),
// Selected Employees Chips
_buildSelectedEmployees(), _buildSelectedEmployees(),
MySpacing.height(8),
// Target Input
_buildTextField( _buildTextField(
icon: Icons.track_changes, icon: Icons.track_changes,
label: "Target for Today :", label: "Target for Today :",
controller: targetController, controller: targetController,
hintText: "Enter target", hintText: "Enter target",
keyboardType: TextInputType.number, keyboardType: const TextInputType.numberWithOptions(decimal: true),
validatorType: "target", validatorType: "target",
), ),
MySpacing.height(24), MySpacing.height(16),
// Description Input
_buildTextField( _buildTextField(
icon: Icons.description, icon: Icons.description,
label: "Description :", label: "Description :",
@ -122,8 +198,7 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
} }
void _onRoleMenuPressed() { void _onRoleMenuPressed() {
final RenderBox overlay = final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
Overlay.of(context).context.findRenderObject() as RenderBox;
final Size screenSize = overlay.size; final Size screenSize = overlay.size;
showMenu( showMenu(
@ -144,27 +219,24 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
}), }),
], ],
).then((value) { ).then((value) {
if (value != null) { if (value != null) controller.onRoleSelected(value == 'all' ? null : value);
controller.onRoleSelected(value == 'all' ? null : value);
}
}); });
} }
Widget _buildEmployeeList() { Widget _buildEmployeeList() {
return Obx(() { return Obx(() {
if (controller.isLoading.value) { if (controller.isFetchingEmployees.value) {
return const Center(child: CircularProgressIndicator()); return Center(child: CircularProgressIndicator());
} }
final selectedRoleId = controller.selectedRoleId.value; final filteredEmployees = controller.selectedRoleId.value == null
final filteredEmployees = selectedRoleId == null
? controller.employees ? controller.employees
: controller.employees : controller.employees
.where((e) => e.jobRoleID.toString() == selectedRoleId) .where((e) => e.jobRoleID.toString() == controller.selectedRoleId.value)
.toList(); .toList();
if (filteredEmployees.isEmpty) { if (filteredEmployees.isEmpty) {
return const Text("No employees found for selected role."); return Center(child: Text("No employees available for selected role."));
} }
return Scrollbar( return Scrollbar(
@ -172,43 +244,32 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
thumbVisibility: true, thumbVisibility: true,
child: ListView.builder( child: ListView.builder(
controller: _employeeListScrollController, controller: _employeeListScrollController,
shrinkWrap: true,
itemCount: filteredEmployees.length, itemCount: filteredEmployees.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final employee = filteredEmployees[index]; final employee = filteredEmployees[index];
final rxBool = controller.uploadingStates[employee.id]; final rxBool = controller.uploadingStates[employee.id];
return Obx(() => Padding( return Obx(() => ListTile(
padding: const EdgeInsets.symmetric(vertical: 2), dense: true,
child: Row( contentPadding: const EdgeInsets.symmetric(horizontal: 8),
children: [ leading: Checkbox(
Checkbox( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
shape: RoundedRectangleBorder( value: rxBool?.value ?? false,
borderRadius: BorderRadius.circular(4), onChanged: (selected) {
), if (rxBool != null) {
value: rxBool?.value ?? false, rxBool.value = selected ?? false;
onChanged: (bool? selected) { controller.updateSelectedEmployees();
if (rxBool != null) { }
rxBool.value = selected ?? false; },
controller.updateSelectedEmployees(); fillColor: MaterialStateProperty.resolveWith((states) =>
} states.contains(MaterialState.selected)
}, ? const Color.fromARGB(255, 95, 132, 255)
fillColor: : Colors.transparent),
WidgetStateProperty.resolveWith<Color>((states) { checkColor: Colors.white,
if (states.contains(WidgetState.selected)) { side: const BorderSide(color: Colors.black),
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))),
],
), ),
title: Text(employee.name, style: const TextStyle(fontSize: 14)),
visualDensity: VisualDensity.compact,
)); ));
}, },
), ),
@ -220,30 +281,25 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
return Obx(() { return Obx(() {
if (controller.selectedEmployees.isEmpty) return Container(); if (controller.selectedEmployees.isEmpty) return Container();
return Padding( return Wrap(
padding: const EdgeInsets.symmetric(vertical: 8.0), spacing: 4,
child: Wrap( runSpacing: 4,
spacing: 4, children: controller.selectedEmployees.map((e) {
runSpacing: 4, return Obx(() {
children: controller.selectedEmployees.map((e) { final isSelected = controller.uploadingStates[e.id]?.value ?? false;
return Obx(() { if (!isSelected) return Container();
final isSelected =
controller.uploadingStates[e.id]?.value ?? false;
if (!isSelected) return Container();
return Chip( return Chip(
label: label: Text(e.name, style: const TextStyle(color: Colors.white)),
Text(e.name, style: const TextStyle(color: Colors.white)), backgroundColor: const Color.fromARGB(255, 95, 132, 255),
backgroundColor: const Color.fromARGB(255, 95, 132, 255), deleteIcon: const Icon(Icons.close, color: Colors.white),
deleteIcon: const Icon(Icons.close, color: Colors.white), onDeleted: () {
onDeleted: () { controller.uploadingStates[e.id]?.value = false;
controller.uploadingStates[e.id]?.value = false; controller.updateSelectedEmployees();
controller.updateSelectedEmployees(); },
}, );
); });
}); }).toList(),
}).toList(),
),
); );
}); });
} }
@ -260,24 +316,22 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(children: [
children: [ Icon(icon, size: 18, color: Colors.black54),
Icon(icon, size: 18, color: Colors.black54), const SizedBox(width: 6),
const SizedBox(width: 6), MyText.titleMedium(label, fontWeight: 600),
MyText.titleMedium(label, fontWeight: 600), ]),
],
),
MySpacing.height(6), MySpacing.height(6),
TextFormField( TextFormField(
controller: controller, controller: controller,
keyboardType: keyboardType, keyboardType: keyboardType,
maxLines: maxLines, maxLines: maxLines,
decoration: const InputDecoration( decoration: InputDecoration(
hintText: '', hintText: hintText,
border: OutlineInputBorder(), border: OutlineInputBorder(borderRadius: BorderRadius.circular(6)),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
), ),
validator: (value) => validator: (value) => this.controller.formFieldValidator(value, fieldType: validatorType),
this.controller.formFieldValidator(value, fieldType: validatorType),
), ),
], ],
); );
@ -296,13 +350,9 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
text: TextSpan( text: TextSpan(
children: [ children: [
WidgetSpan( WidgetSpan(
child: MyText.titleMedium("$title: ", child: MyText.titleMedium("$title: ", fontWeight: 600, color: Colors.black),
fontWeight: 600, color: Colors.black),
),
TextSpan(
text: value,
style: const TextStyle(color: Colors.black),
), ),
TextSpan(text: value, style: const TextStyle(color: Colors.black)),
], ],
), ),
), ),
@ -319,29 +369,20 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
.toList(); .toList();
if (selectedTeam.isEmpty) { if (selectedTeam.isEmpty) {
showAppSnackbar( showAppSnackbar(title: "Team Required", message: "Please select at least one team member", type: SnackbarType.error);
title: "Team Required",
message: "Please select at least one team member",
type: SnackbarType.error,
);
return; return;
} }
final target = int.tryParse(targetController.text.trim()); final target = double.tryParse(targetController.text.trim());
if (target == null || target <= 0) { if (target == null || target <= 0) {
showAppSnackbar( showAppSnackbar(title: "Invalid Input", message: "Please enter a valid target number", type: SnackbarType.error);
title: "Invalid Input",
message: "Please enter a valid target number",
type: SnackbarType.error,
);
return; return;
} }
if (target > widget.pendingTask) { if (target > widget.pendingTask) {
showAppSnackbar( showAppSnackbar(
title: "Target Too High", title: "Target Too High",
message: message: "Target cannot exceed pending task (${widget.pendingTask})",
"Target cannot be greater than pending task (${widget.pendingTask})",
type: SnackbarType.error, type: SnackbarType.error,
); );
return; return;
@ -349,20 +390,18 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
final description = descriptionController.text.trim(); final description = descriptionController.text.trim();
if (description.isEmpty) { if (description.isEmpty) {
showAppSnackbar( showAppSnackbar(title: "Description Required", message: "Please enter a description", type: SnackbarType.error);
title: "Description Required",
message: "Please enter a description",
type: SnackbarType.error,
);
return; return;
} }
controller.assignDailyTask( controller.assignDailyTask(
workItemId: widget.workItemId, workItemId: widget.workItemId,
plannedTask: target, plannedTask: target.toInt(),
description: description, description: description,
taskTeam: selectedTeam, taskTeam: selectedTeam,
assignmentDate: widget.assignmentDate, assignmentDate: widget.assignmentDate,
organizationId: selectedOrganization?.id,
serviceId: selectedService?.id,
); );
} }
} }

View File

@ -249,7 +249,7 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSectionHeader("Add Comment", Icons.comment_outlined), _buildSectionHeader("Add Note", Icons.comment_outlined),
MySpacing.height(8), MySpacing.height(8),
TextFormField( TextFormField(
validator: validator:

View File

@ -1,82 +1,289 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:get/get.dart';
import 'package:marco/controller/task_planning/daily_task_controller.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/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.dart';
class DailyProgressReportFilter extends StatelessWidget { class DailyTaskFilterBottomSheet extends StatelessWidget {
final DailyTaskController controller; final DailyTaskController controller;
final PermissionController permissionController;
const DailyProgressReportFilter({ const DailyTaskFilterBottomSheet({super.key, required this.controller});
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final filterData = controller.taskFilterData;
if (filterData == null) return const SizedBox.shrink();
final hasFilters = [
filterData.buildings,
filterData.floors,
filterData.activities,
filterData.services,
].any((list) => list.isNotEmpty);
return BaseBottomSheet( return BaseBottomSheet(
title: "Filter Tasks", title: "Filter Tasks",
onCancel: () => Navigator.pop(context), submitText: "Apply",
showButtons: hasFilters,
onCancel: () => Get.back(),
onSubmit: () { onSubmit: () {
Navigator.pop(context, { if (controller.selectedProjectId != null) {
'startDate': controller.startDateTask, controller.fetchTaskData(
'endDate': controller.endDateTask, controller.selectedProjectId!,
}); );
}
Get.back();
}, },
child: Column( child: SingleChildScrollView(
crossAxisAlignment: CrossAxisAlignment.start, child: hasFilters
children: [ ? Column(
MyText.titleSmall("Select Date Range", fontWeight: 600), crossAxisAlignment: CrossAxisAlignment.start,
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: [ children: [
Icon(Icons.date_range, color: Colors.blue.shade600), Align(
const SizedBox(width: 12), alignment: Alignment.centerRight,
Expanded( child: TextButton(
child: Text( onPressed: () {
getLabelText(), controller.clearTaskFilters();
style: const TextStyle( },
fontSize: 16, child: MyText(
color: Colors.black87, "Reset Filter",
fontWeight: FontWeight.w500, style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.w600,
),
), ),
overflow: TextOverflow.ellipsis,
), ),
), ),
const Icon(Icons.arrow_drop_down, color: Colors.grey), MySpacing.height(8),
_multiSelectField(
label: "Buildings",
items: filterData.buildings,
fallback: "Select Buildings",
selectedValues: controller.selectedBuildings,
),
_multiSelectField(
label: "Floors",
items: filterData.floors,
fallback: "Select Floors",
selectedValues: controller.selectedFloors,
),
_multiSelectField(
label: "Activities",
items: filterData.activities,
fallback: "Select Activities",
selectedValues: controller.selectedActivities,
),
_multiSelectField(
label: "Services",
items: filterData.services,
fallback: "Select Services",
selectedValues: controller.selectedServices,
),
MySpacing.height(8),
_dateRangeSelector(context),
], ],
)
: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: MyText(
"No filters available",
style: const TextStyle(color: Colors.grey),
),
),
),
),
);
}
Widget _multiSelectField({
required String label,
required List<dynamic> items,
required String fallback,
required RxSet<String> selectedValues,
}) {
if (items.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
MySpacing.height(8),
Obx(() {
final selectedNames = items
.where((item) => selectedValues.contains(item.id))
.map((item) => item.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: items.map((item) {
return PopupMenuItem<String>(
enabled: false,
child: StatefulBuilder(
builder: (context, setState) {
final isChecked = selectedValues.contains(item.id);
return CheckboxListTile(
dense: true,
value: isChecked,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: MyText(item.name),
// --- Styles to match Document Filter ---
checkColor: Colors.white,
side: const BorderSide(
color: Colors.black, width: 1.5),
fillColor:
MaterialStateProperty.resolveWith<Color>(
(states) {
if (states.contains(MaterialState.selected)) {
return Colors.indigo;
}
return Colors.white;
},
),
onChanged: (val) {
if (val == true) {
selectedValues.add(item.id);
} else {
selectedValues.remove(item.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),
],
),
),
);
},
);
}),
MySpacing.height(16),
],
);
}
Widget _dateRangeSelector(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium("Select Date Range"),
MySpacing.height(8),
Row(
children: [
Expanded(
child: _dateButton(
label: controller.startDateTask != null
? "${controller.startDateTask!.day}/${controller.startDateTask!.month}/${controller.startDateTask!.year}"
: "From Date",
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: controller.startDateTask ?? DateTime.now(),
firstDate: DateTime(2022),
lastDate: DateTime.now(),
);
if (picked != null) {
controller.startDateTask = picked;
controller.update(); // rebuild widget
}
},
), ),
), ),
), MySpacing.width(12),
], Expanded(
child: _dateButton(
label: controller.endDateTask != null
? "${controller.endDateTask!.day}/${controller.endDateTask!.month}/${controller.endDateTask!.year}"
: "To Date",
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: controller.endDateTask ?? DateTime.now(),
firstDate: DateTime(2022),
lastDate: DateTime.now(),
);
if (picked != null) {
controller.endDateTask = picked;
controller.update();
}
},
),
),
],
),
MySpacing.height(16),
],
);
}
Widget _dateButton({required String label, required VoidCallback onTap}) {
return GestureDetector(
onTap: onTap,
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(
children: [
const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
const SizedBox(width: 8),
Expanded(
child: MyText(label),
),
],
),
), ),
); );
} }

View File

@ -0,0 +1,128 @@
class DailyProgressReportFilterResponse {
final bool success;
final String message;
final FilterData? data;
final dynamic errors;
final int statusCode;
final String timestamp;
DailyProgressReportFilterResponse({
required this.success,
required this.message,
this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory DailyProgressReportFilterResponse.fromJson(Map<String, dynamic> json) {
return DailyProgressReportFilterResponse(
success: json['success'],
message: json['message'],
data: json['data'] != null ? FilterData.fromJson(json['data']) : null,
errors: json['errors'],
statusCode: json['statusCode'],
timestamp: json['timestamp'],
);
}
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp,
};
}
class FilterData {
final List<Building> buildings;
final List<Floor> floors;
final List<Activity> activities;
final List<Service> services;
FilterData({
required this.buildings,
required this.floors,
required this.activities,
required this.services,
});
factory FilterData.fromJson(Map<String, dynamic> json) {
return FilterData(
buildings: (json['buildings'] as List)
.map((e) => Building.fromJson(e))
.toList(),
floors:
(json['floors'] as List).map((e) => Floor.fromJson(e)).toList(),
activities:
(json['activities'] as List).map((e) => Activity.fromJson(e)).toList(),
services:
(json['services'] as List).map((e) => Service.fromJson(e)).toList(),
);
}
Map<String, dynamic> toJson() => {
'buildings': buildings.map((e) => e.toJson()).toList(),
'floors': floors.map((e) => e.toJson()).toList(),
'activities': activities.map((e) => e.toJson()).toList(),
'services': services.map((e) => e.toJson()).toList(),
};
}
class Building {
final String id;
final String name;
Building({required this.id, required this.name});
factory Building.fromJson(Map<String, dynamic> json) =>
Building(id: json['id'], name: json['name']);
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}
class Floor {
final String id;
final String name;
final String buildingId;
Floor({required this.id, required this.name, required this.buildingId});
factory Floor.fromJson(Map<String, dynamic> json) => Floor(
id: json['id'],
name: json['name'],
buildingId: json['buildingId'],
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'buildingId': buildingId,
};
}
class Activity {
final String id;
final String name;
Activity({required this.id, required this.name});
factory Activity.fromJson(Map<String, dynamic> json) =>
Activity(id: json['id'], name: json['name']);
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}
class Service {
final String id;
final String name;
Service({required this.id, required this.name});
factory Service.fromJson(Map<String, dynamic> json) =>
Service(id: json['id'], name: json['name']);
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}

View File

@ -16,38 +16,36 @@ class TaskModel {
required this.assignmentDate, required this.assignmentDate,
this.reportedDate, this.reportedDate,
required this.id, required this.id,
required this.workItem, this.workItem,
required this.workItemId, required this.workItemId,
required this.plannedTask, required this.plannedTask,
required this.completedTask, required this.completedTask,
required this.assignedBy, required this.assignedBy,
this.approvedBy, this.approvedBy,
required this.teamMembers, this.teamMembers = const [],
required this.comments, this.comments = const [],
required this.reportedPreSignedUrls, this.reportedPreSignedUrls = const [],
}); });
factory TaskModel.fromJson(Map<String, dynamic> json) { factory TaskModel.fromJson(Map<String, dynamic> json) {
return TaskModel( return TaskModel(
assignmentDate: DateTime.parse(json['assignmentDate']), assignmentDate: DateTime.parse(json['assignmentDate'] ?? DateTime.now().toIso8601String()),
reportedDate: json['reportedDate'] != null reportedDate: json['reportedDate'] != null ? DateTime.tryParse(json['reportedDate']) : null,
? DateTime.tryParse(json['reportedDate']) id: json['id']?.toString() ?? '',
: null, workItem: json['workItem'] != null ? WorkItem.fromJson(json['workItem']) : null,
id: json['id'], workItemId: json['workItemId']?.toString() ?? '',
workItem: plannedTask: (json['plannedTask'] as num?)?.toDouble() ?? 0,
json['workItem'] != null ? WorkItem.fromJson(json['workItem']) : null, completedTask: (json['completedTask'] as num?)?.toDouble() ?? 0,
workItemId: json['workItemId'], assignedBy: AssignedBy.fromJson(json['assignedBy'] ?? {}),
plannedTask: (json['plannedTask'] as num).toDouble(), approvedBy: json['approvedBy'] != null ? AssignedBy.fromJson(json['approvedBy']) : null,
completedTask: (json['completedTask'] as num).toDouble(), teamMembers: (json['teamMembers'] as List<dynamic>?)
assignedBy: AssignedBy.fromJson(json['assignedBy']), ?.map((e) => TeamMember.fromJson(e))
approvedBy: json['approvedBy'] != null .toList() ??
? AssignedBy.fromJson(json['approvedBy']) [],
: null, comments: (json['comments'] as List<dynamic>?)
teamMembers: (json['teamMembers'] as List) ?.map((e) => Comment.fromJson(e))
.map((e) => TeamMember.fromJson(e)) .toList() ??
.toList(), [],
comments:
(json['comments'] as List).map((e) => Comment.fromJson(e)).toList(),
reportedPreSignedUrls: (json['reportedPreSignedUrls'] as List<dynamic>?) reportedPreSignedUrls: (json['reportedPreSignedUrls'] as List<dynamic>?)
?.map((e) => e.toString()) ?.map((e) => e.toString())
.toList() ?? .toList() ??
@ -79,8 +77,7 @@ class WorkItem {
activityMaster: json['activityMaster'] != null activityMaster: json['activityMaster'] != null
? ActivityMaster.fromJson(json['activityMaster']) ? ActivityMaster.fromJson(json['activityMaster'])
: null, : null,
workArea: workArea: json['workArea'] != null ? WorkArea.fromJson(json['workArea']) : null,
json['workArea'] != null ? WorkArea.fromJson(json['workArea']) : null,
plannedWork: (json['plannedWork'] as num?)?.toDouble(), plannedWork: (json['plannedWork'] as num?)?.toDouble(),
completedWork: (json['completedWork'] as num?)?.toDouble(), completedWork: (json['completedWork'] as num?)?.toDouble(),
preSignedUrls: (json['preSignedUrls'] as List<dynamic>?) preSignedUrls: (json['preSignedUrls'] as List<dynamic>?)
@ -92,7 +89,7 @@ class WorkItem {
} }
class ActivityMaster { class ActivityMaster {
final String? id; // Added final String? id;
final String activityName; final String activityName;
ActivityMaster({ ActivityMaster({
@ -103,13 +100,13 @@ class ActivityMaster {
factory ActivityMaster.fromJson(Map<String, dynamic> json) { factory ActivityMaster.fromJson(Map<String, dynamic> json) {
return ActivityMaster( return ActivityMaster(
id: json['id']?.toString(), id: json['id']?.toString(),
activityName: json['activityName'] ?? '', activityName: json['activityName']?.toString() ?? '',
); );
} }
} }
class WorkArea { class WorkArea {
final String? id; // Added final String? id;
final String areaName; final String areaName;
final Floor? floor; final Floor? floor;
@ -122,7 +119,7 @@ class WorkArea {
factory WorkArea.fromJson(Map<String, dynamic> json) { factory WorkArea.fromJson(Map<String, dynamic> json) {
return WorkArea( return WorkArea(
id: json['id']?.toString(), id: json['id']?.toString(),
areaName: json['areaName'] ?? '', areaName: json['areaName']?.toString() ?? '',
floor: json['floor'] != null ? Floor.fromJson(json['floor']) : null, floor: json['floor'] != null ? Floor.fromJson(json['floor']) : null,
); );
} }
@ -136,9 +133,8 @@ class Floor {
factory Floor.fromJson(Map<String, dynamic> json) { factory Floor.fromJson(Map<String, dynamic> json) {
return Floor( return Floor(
floorName: json['floorName'] ?? '', floorName: json['floorName']?.toString() ?? '',
building: building: json['building'] != null ? Building.fromJson(json['building']) : null,
json['building'] != null ? Building.fromJson(json['building']) : null,
); );
} }
} }
@ -149,7 +145,7 @@ class Building {
Building({required this.name}); Building({required this.name});
factory Building.fromJson(Map<String, dynamic> json) { factory Building.fromJson(Map<String, dynamic> json) {
return Building(name: json['name'] ?? ''); return Building(name: json['name']?.toString() ?? '');
} }
} }
@ -167,8 +163,8 @@ class AssignedBy {
factory AssignedBy.fromJson(Map<String, dynamic> json) { factory AssignedBy.fromJson(Map<String, dynamic> json) {
return AssignedBy( return AssignedBy(
id: json['id']?.toString() ?? '', id: json['id']?.toString() ?? '',
firstName: json['firstName'] ?? '', firstName: json['firstName']?.toString() ?? '',
lastName: json['lastName'], lastName: json['lastName']?.toString(),
); );
} }
} }
@ -203,7 +199,7 @@ class Comment {
required this.comment, required this.comment,
required this.commentedBy, required this.commentedBy,
required this.timestamp, required this.timestamp,
required this.preSignedUrls, this.preSignedUrls = const [],
}); });
factory Comment.fromJson(Map<String, dynamic> json) { factory Comment.fromJson(Map<String, dynamic> json) {
@ -212,7 +208,9 @@ class Comment {
commentedBy: json['employee'] != null commentedBy: json['employee'] != null
? TeamMember.fromJson(json['employee']) ? TeamMember.fromJson(json['employee'])
: TeamMember(id: '', firstName: '', lastName: null), : TeamMember(id: '', firstName: '', lastName: null),
timestamp: DateTime.parse(json['commentDate'] ?? ''), timestamp: json['commentDate'] != null
? DateTime.parse(json['commentDate'])
: DateTime.now(),
preSignedUrls: (json['preSignedUrls'] as List<dynamic>?) preSignedUrls: (json['preSignedUrls'] as List<dynamic>?)
?.map((e) => e.toString()) ?.map((e) => e.toString())
.toList() ?? .toList() ??

View File

@ -129,6 +129,8 @@ class WorkItem {
final WorkCategoryMaster? workCategoryMaster; final WorkCategoryMaster? workCategoryMaster;
final double? plannedWork; final double? plannedWork;
final double? completedWork; final double? completedWork;
final String? description;
final double? todaysAssigned;
final DateTime? taskDate; final DateTime? taskDate;
final String? tenantId; final String? tenantId;
final Tenant? tenant; final Tenant? tenant;
@ -141,8 +143,10 @@ class WorkItem {
this.workArea, this.workArea,
this.activityMaster, this.activityMaster,
this.workCategoryMaster, this.workCategoryMaster,
this.description,
this.plannedWork, this.plannedWork,
this.completedWork, this.completedWork,
this.todaysAssigned,
this.taskDate, this.taskDate,
this.tenantId, this.tenantId,
this.tenant, this.tenant,
@ -171,6 +175,10 @@ class WorkItem {
completedWork: json['completedWork'] != null completedWork: json['completedWork'] != null
? (json['completedWork'] as num).toDouble() ? (json['completedWork'] as num).toDouble()
: null, : null,
todaysAssigned: json['todaysAssigned'] != null
? (json['todaysAssigned'] as num).toDouble()
: null,
description: json['description'] as String?,
taskDate: taskDate:
json['taskDate'] != null ? DateTime.tryParse(json['taskDate']) : null, json['taskDate'] != null ? DateTime.tryParse(json['taskDate']) : null,
tenantId: json['tenantId'] as String?, tenantId: json['tenantId'] as String?,

View File

@ -147,8 +147,9 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
floatingLabelBehavior: FloatingLabelBehavior.never, floatingLabelBehavior: FloatingLabelBehavior.never,
), ),
), ),
MySpacing.height(10), MySpacing.height(10),
// Reported Images Section
if ((widget.taskData['reportedPreSignedUrls'] as List<dynamic>?) if ((widget.taskData['reportedPreSignedUrls'] as List<dynamic>?)
?.isNotEmpty == ?.isNotEmpty ==
true) true)
@ -157,39 +158,37 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
widget.taskData['reportedPreSignedUrls'] ?? []), widget.taskData['reportedPreSignedUrls'] ?? []),
context: context, context: context,
), ),
MySpacing.height(10), MySpacing.height(10),
// Report Actions Dropdown
MyText.titleSmall("Report Actions", fontWeight: 600), MyText.titleSmall("Report Actions", fontWeight: 600),
MySpacing.height(10), MySpacing.height(10),
Obx(() { Obx(() {
if (controller.isLoadingWorkStatus.value) if (controller.isLoadingWorkStatus.value)
return const CircularProgressIndicator(); return const CircularProgressIndicator();
return PopupMenuButton<String>( return PopupMenuButton<String>(
onSelected: (String value) { onSelected: (value) {
controller.selectedWorkStatusName.value = value; controller.selectedWorkStatusName.value = value;
controller.showAddTaskCheckbox.value = true; controller.showAddTaskCheckbox.value = true;
}, },
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)), borderRadius: BorderRadius.circular(12)),
itemBuilder: (BuildContext context) { itemBuilder: (context) => controller.workStatus.map((status) {
return controller.workStatus.map((status) { return PopupMenuItem<String>(
return PopupMenuItem<String>( value: status.name,
value: status.name, child: Row(
child: Row( children: [
children: [ Radio<String>(
Radio<String>( value: status.name,
value: status.name, groupValue: controller.selectedWorkStatusName.value,
groupValue: controller.selectedWorkStatusName.value, onChanged: (_) => Navigator.pop(context, status.name),
onChanged: (_) => Navigator.pop(context, status.name), ),
), const SizedBox(width: 8),
const SizedBox(width: 8), MyText.bodySmall(status.name),
MyText.bodySmall(status.name), ],
], ),
), );
); }).toList(),
}).toList();
},
child: Container( child: Container(
padding: MySpacing.xy(16, 12), padding: MySpacing.xy(16, 12),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -211,9 +210,9 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
), ),
); );
}), }),
MySpacing.height(10), MySpacing.height(10),
// Add New Task Checkbox
Obx(() { Obx(() {
if (!controller.showAddTaskCheckbox.value) if (!controller.showAddTaskCheckbox.value)
return const SizedBox.shrink(); return const SizedBox.shrink();
@ -221,19 +220,15 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
data: Theme.of(context).copyWith( data: Theme.of(context).copyWith(
checkboxTheme: CheckboxThemeData( checkboxTheme: CheckboxThemeData(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4)),
), side: const BorderSide(color: Colors.black, width: 2),
side: const BorderSide(
color: Colors.black, width: 2),
fillColor: MaterialStateProperty.resolveWith<Color>((states) { fillColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) { if (states.contains(MaterialState.selected))
return Colors.blueAccent; return Colors.blueAccent;
} return Colors.white;
return Colors.white;
}), }),
checkColor: checkColor: MaterialStateProperty.all(Colors.white),
MaterialStateProperty.all(Colors.white), ),
),
), ),
child: CheckboxListTile( child: CheckboxListTile(
title: MyText.titleSmall("Add new task", fontWeight: 600), title: MyText.titleSmall("Add new task", fontWeight: 600),
@ -245,10 +240,9 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
), ),
); );
}), }),
MySpacing.height(24), MySpacing.height(24),
// Comment Field // 💬 Comment Field
Row( Row(
children: [ children: [
Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]), Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]),
@ -258,8 +252,8 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
), ),
MySpacing.height(8), MySpacing.height(8),
TextFormField( TextFormField(
validator: controller.basicValidator.getValidation('comment'),
controller: controller.basicValidator.getController('comment'), controller: controller.basicValidator.getController('comment'),
validator: controller.basicValidator.getValidation('comment'),
keyboardType: TextInputType.text, keyboardType: TextInputType.text,
decoration: InputDecoration( decoration: InputDecoration(
hintText: "eg: Work done successfully", hintText: "eg: Work done successfully",
@ -269,10 +263,9 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
floatingLabelBehavior: FloatingLabelBehavior.never, floatingLabelBehavior: FloatingLabelBehavior.never,
), ),
), ),
MySpacing.height(16), MySpacing.height(16),
// 📸 Image Attachments // 📸 Attach Photos
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -297,21 +290,18 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
onCameraTap: () => controller.pickImages(fromCamera: true), onCameraTap: () => controller.pickImages(fromCamera: true),
onUploadTap: () => controller.pickImages(fromCamera: false), onUploadTap: () => controller.pickImages(fromCamera: false),
onRemoveImage: (index) => controller.removeImageAt(index), onRemoveImage: (index) => controller.removeImageAt(index),
onPreviewImage: (index) { onPreviewImage: (index) => showDialog(
showDialog( context: context,
context: context, builder: (_) => ImageViewerDialog(
builder: (_) => ImageViewerDialog( imageSources: images,
imageSources: images, initialIndex: index,
initialIndex: index, ),
), ),
);
},
); );
}), }),
MySpacing.height(12), MySpacing.height(12),
// Submit/Cancel Buttons moved here // Submit/Cancel Buttons
Row( Row(
children: [ children: [
Expanded( Expanded(
@ -347,7 +337,6 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
?.text ?.text
.trim() ?? .trim() ??
''; '';
final shouldShowAddTaskSheet = final shouldShowAddTaskSheet =
controller.isAddTaskChecked.value; controller.isAddTaskChecked.value;
@ -408,10 +397,9 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
), ),
], ],
), ),
MySpacing.height(12), MySpacing.height(12),
// 💬 Previous Comments List (only below submit) // 💬 Previous Comments
if ((widget.taskData['taskComments'] as List<dynamic>?)?.isNotEmpty == if ((widget.taskData['taskComments'] as List<dynamic>?)?.isNotEmpty ==
true) ...[ true) ...[
Row( Row(

View File

@ -65,7 +65,7 @@ class _AddCommentBottomSheetState extends State<AddCommentBottomSheet> {
), ),
), ),
MySpacing.height(12), MySpacing.height(12),
Center(child: MyText.titleMedium("Add Comment", fontWeight: 700)), Center(child: MyText.titleMedium("Add Note", fontWeight: 700)),
MySpacing.height(24), MySpacing.height(24),
CommentEditorCard( CommentEditorCard(
controller: quillController, controller: quillController,

View File

@ -24,6 +24,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
final nameCtrl = TextEditingController(); final nameCtrl = TextEditingController();
final orgCtrl = TextEditingController(); final orgCtrl = TextEditingController();
final designationCtrl = TextEditingController();
final addrCtrl = TextEditingController(); final addrCtrl = TextEditingController();
final descCtrl = TextEditingController(); final descCtrl = TextEditingController();
final tagCtrl = TextEditingController(); final tagCtrl = TextEditingController();
@ -49,6 +50,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
if (c != null) { if (c != null) {
nameCtrl.text = c.name; nameCtrl.text = c.name;
orgCtrl.text = c.organization; orgCtrl.text = c.organization;
designationCtrl.text = c.designation ?? '';
addrCtrl.text = c.address; addrCtrl.text = c.address;
descCtrl.text = c.description; descCtrl.text = c.description;
@ -72,12 +74,20 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
ever(controller.isInitialized, (bool ready) { ever(controller.isInitialized, (bool ready) {
if (ready) { if (ready) {
// Buckets - map all
if (c.bucketIds.isNotEmpty) {
final names = c.bucketIds
.map((id) {
return controller.bucketsMap.entries
.firstWhereOrNull((e) => e.value == id)
?.key;
})
.whereType<String>()
.toList();
controller.selectedBuckets.assignAll(names);
}
// Projects and Category mapping - as before
final projectIds = c.projectIds; final projectIds = c.projectIds;
final bucketId = c.bucketIds.firstOrNull;
final category = c.contactCategory?.name;
if (category != null) controller.selectedCategory.value = category;
if (projectIds != null) { if (projectIds != null) {
controller.selectedProjects.assignAll( controller.selectedProjects.assignAll(
projectIds projectIds
@ -88,16 +98,12 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
.toList(), .toList(),
); );
} }
final category = c.contactCategory?.name;
if (bucketId != null) { if (category != null) controller.selectedCategory.value = category;
final name = controller.bucketsMap.entries
.firstWhereOrNull((e) => e.value == bucketId)
?.key;
if (name != null) controller.selectedBucket.value = name;
}
} }
}); });
} else { } else {
showAdvanced.value = false; // Optional
emailCtrls.add(TextEditingController()); emailCtrls.add(TextEditingController());
emailLabels.add('Office'.obs); emailLabels.add('Office'.obs);
phoneCtrls.add(TextEditingController()); phoneCtrls.add(TextEditingController());
@ -109,6 +115,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
void dispose() { void dispose() {
nameCtrl.dispose(); nameCtrl.dispose();
orgCtrl.dispose(); orgCtrl.dispose();
designationCtrl.dispose();
addrCtrl.dispose(); addrCtrl.dispose();
descCtrl.dispose(); descCtrl.dispose();
tagCtrl.dispose(); tagCtrl.dispose();
@ -118,6 +125,20 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
super.dispose(); super.dispose();
} }
Widget _labelWithStar(String label, {bool required = false}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
MyText.labelMedium(label),
if (required)
const Text(
" *",
style: TextStyle(color: Colors.red, fontSize: 14),
),
],
);
}
InputDecoration _inputDecoration(String hint) => InputDecoration( InputDecoration _inputDecoration(String hint) => InputDecoration(
hintText: hint, hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true), hintStyle: MyTextStyle.bodySmall(xMuted: true),
@ -145,7 +166,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.labelMedium(label), _labelWithStar(label, required: required),
MySpacing.height(8), MySpacing.height(8),
TextFormField( TextFormField(
controller: ctrl, controller: ctrl,
@ -346,10 +367,129 @@ 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 {
@ -386,6 +526,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
phones: phones, phones: phones,
address: addrCtrl.text.trim(), address: addrCtrl.text.trim(),
description: descCtrl.text.trim(), description: descCtrl.text.trim(),
designation: designationCtrl.text.trim(),
); );
} }
@ -412,29 +553,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),
MyText.labelMedium("Select Bucket"), _labelWithStar("Buckets", required: true),
MySpacing.height(8), MySpacing.height(8),
Stack( Stack(
children: [ children: [
_popupSelector(controller.selectedBucket, controller.buckets, _bucketMultiSelectField(),
"Select 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(
@ -477,19 +603,63 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text("Add Phone"), label: const Text("Add Phone"),
), ),
MySpacing.height(16), Obx(() => showAdvanced.value
MyText.labelMedium("Category"), ? Column(
MySpacing.height(8), crossAxisAlignment: CrossAxisAlignment.start,
_popupSelector(controller.selectedCategory, children: [
controller.categories, "Select Category"), // Move Designation field here
MySpacing.height(16), _textField("Designation", designationCtrl),
MyText.labelMedium("Tags"), MySpacing.height(16),
MySpacing.height(8),
_tagInput(), _dynamicList(
MySpacing.height(16), emailCtrls,
_textField("Address", addrCtrl), emailLabels,
MySpacing.height(16), "Email",
_textField("Description", descCtrl), ["Office", "Personal", "Other"],
TextInputType.emailAddress,
),
TextButton.icon(
onPressed: () {
emailCtrls.add(TextEditingController());
emailLabels.add("Office".obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Email"),
),
_dynamicList(
phoneCtrls,
phoneLabels,
"Phone",
["Work", "Mobile", "Other"],
TextInputType.phone,
),
TextButton.icon(
onPressed: () {
phoneCtrls.add(TextEditingController());
phoneLabels.add("Work".obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Phone"),
),
MyText.labelMedium("Category"),
MySpacing.height(8),
_popupSelector(
controller.selectedCategory,
controller.categories,
"Choose Category",
),
MySpacing.height(16),
MyText.labelMedium("Tags"),
MySpacing.height(8),
_tagInput(),
MySpacing.height(16),
_textField("Address", addrCtrl),
MySpacing.height(16),
_textField("Description", descCtrl,
maxLines: 3),
],
)
: const SizedBox.shrink()),
], ],
) )
: const SizedBox.shrink()), : const SizedBox.shrink()),
@ -500,3 +670,9 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
}); });
} }
} }
class FilterItem {
final String id;
final String name;
FilterItem({required this.id, required this.name});
}

View File

@ -2,6 +2,7 @@ class ContactModel {
final String id; final String id;
final List<String>? projectIds; final List<String>? projectIds;
final String name; final String name;
final String? designation;
final List<ContactPhone> contactPhones; final List<ContactPhone> contactPhones;
final List<ContactEmail> contactEmails; final List<ContactEmail> contactEmails;
final ContactCategory? contactCategory; final ContactCategory? contactCategory;
@ -15,6 +16,7 @@ class ContactModel {
required this.id, required this.id,
required this.projectIds, required this.projectIds,
required this.name, required this.name,
this.designation,
required this.contactPhones, required this.contactPhones,
required this.contactEmails, required this.contactEmails,
required this.contactCategory, required this.contactCategory,
@ -30,6 +32,7 @@ class ContactModel {
id: json['id'], id: json['id'],
projectIds: (json['projectIds'] as List?)?.map((e) => e as String).toList(), projectIds: (json['projectIds'] as List?)?.map((e) => e as String).toList(),
name: json['name'], name: json['name'],
designation: json['designation'],
contactPhones: (json['contactPhones'] as List) contactPhones: (json['contactPhones'] as List)
.map((e) => ContactPhone.fromJson(e)) .map((e) => ContactPhone.fromJson(e))
.toList(), .toList(),
@ -48,6 +51,7 @@ class ContactModel {
} }
} }
class ContactPhone { class ContactPhone {
final String id; final String id;
final String label; final String label;

View File

@ -69,6 +69,15 @@ class DirectoryComment {
isActive: json['isActive'] ?? true, isActive: json['isActive'] ?? true,
); );
} }
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DirectoryComment &&
runtimeType == other.runtimeType &&
id == other.id;
@override
int get hashCode => id.hashCode;
DirectoryComment copyWith({ DirectoryComment copyWith({
String? id, String? id,

View File

@ -79,7 +79,7 @@ class NoteModel {
required this.contactId, required this.contactId,
required this.isActive, required this.isActive,
}); });
NoteModel copyWith({String? note}) => NoteModel( NoteModel copyWith({String? note, bool? isActive}) => NoteModel(
id: id, id: id,
note: note ?? this.note, note: note ?? this.note,
contactName: contactName, contactName: contactName,
@ -89,7 +89,7 @@ class NoteModel {
updatedAt: updatedAt, updatedAt: updatedAt,
updatedBy: updatedBy, updatedBy: updatedBy,
contactId: contactId, contactId: contactId,
isActive: isActive, isActive: isActive ?? this.isActive,
); );
factory NoteModel.fromJson(Map<String, dynamic> json) { factory NoteModel.fromJson(Map<String, dynamic> json) {

View File

@ -194,8 +194,11 @@ class _DocumentUploadBottomSheetState extends State<DocumentUploadBottomSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sheetTitle = widget.isEmployee
? "Upload Employee Document"
: "Upload Project Document";
return BaseBottomSheet( return BaseBottomSheet(
title: "Upload Document", title: sheetTitle,
onCancel: () => Navigator.pop(context), onCancel: () => Navigator.pop(context),
onSubmit: _handleSubmit, onSubmit: _handleSubmit,
child: Form( child: Form(
@ -206,46 +209,6 @@ class _DocumentUploadBottomSheetState extends State<DocumentUploadBottomSheet> {
children: [ children: [
MySpacing.height(16), MySpacing.height(16),
/// Document ID
LabeledInput(
label: "Document ID",
hint: "Enter Document ID",
controller: _docIdController,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "Required";
}
// Regex validation if enabled
final selectedType = controller.selectedType;
if (selectedType != null &&
selectedType.isValidationRequired &&
selectedType.regexExpression != null &&
selectedType.regexExpression!.isNotEmpty) {
final regExp = RegExp(selectedType.regexExpression!);
if (!regExp.hasMatch(value.trim())) {
return "Invalid ${selectedType.name} format";
}
}
return null;
},
isRequired: true,
),
MySpacing.height(16),
/// Document Name
LabeledInput(
label: "Document Name",
hint: "e.g., PAN Card",
controller: _docNameController,
validator: (value) =>
value == null || value.trim().isEmpty ? "Required" : null,
isRequired: true,
),
MySpacing.height(16),
/// Document Category /// Document Category
Obx(() { Obx(() {
if (controller.isLoading.value && if (controller.isLoading.value &&
@ -287,7 +250,47 @@ class _DocumentUploadBottomSheetState extends State<DocumentUploadBottomSheet> {
isRequired: true, isRequired: true,
); );
}), }),
MySpacing.height(24), MySpacing.height(12),
/// Document ID
LabeledInput(
label: "Document ID",
hint: "Enter Document ID",
controller: _docIdController,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "Required";
}
// Regex validation if enabled
final selectedType = controller.selectedType;
if (selectedType != null &&
selectedType.isValidationRequired &&
selectedType.regexExpression != null &&
selectedType.regexExpression!.isNotEmpty) {
final regExp = RegExp(selectedType.regexExpression!);
if (!regExp.hasMatch(value.trim())) {
return "Invalid ${selectedType.name} format";
}
}
return null;
},
isRequired: true,
),
MySpacing.height(16),
/// Document Name
LabeledInput(
label: "Document Name",
hint: "e.g., PAN Card",
controller: _docNameController,
validator: (value) =>
value == null || value.trim().isEmpty ? "Required" : null,
isRequired: true,
),
MySpacing.height(16),
/// Single Attachment Section /// Single Attachment Section
AttachmentSectionSingle( AttachmentSectionSingle(
@ -393,6 +396,7 @@ class _DocumentUploadBottomSheetState extends State<DocumentUploadBottomSheet> {
validator: (value) => validator: (value) =>
value == null || value.trim().isEmpty ? "Required" : null, value == null || value.trim().isEmpty ? "Required" : null,
isRequired: true, isRequired: true,
maxLines: 3,
), ),
], ],
), ),
@ -564,6 +568,7 @@ class LabeledInput extends StatelessWidget {
final TextEditingController controller; final TextEditingController controller;
final String? Function(String?) validator; final String? Function(String?) validator;
final bool isRequired; final bool isRequired;
final int maxLines;
const LabeledInput({ const LabeledInput({
Key? key, Key? key,
@ -572,6 +577,7 @@ class LabeledInput extends StatelessWidget {
required this.controller, required this.controller,
required this.validator, required this.validator,
this.isRequired = false, this.isRequired = false,
this.maxLines = 1,
}) : super(key: key); }) : super(key: key);
@override @override
@ -594,6 +600,7 @@ class LabeledInput extends StatelessWidget {
controller: controller, controller: controller,
validator: validator, validator: validator,
decoration: _inputDecoration(context, hint), decoration: _inputDecoration(context, hint),
maxLines: maxLines,
), ),
], ],
); );

View File

@ -2,10 +2,12 @@ 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';
class UserDocumentFilterBottomSheet extends StatelessWidget { class UserDocumentFilterBottomSheet extends StatelessWidget {
final String entityId; final String entityId;
@ -32,19 +34,26 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
return BaseBottomSheet( return BaseBottomSheet(
title: 'Filter Documents', title: 'Filter Documents',
submitText: 'Apply',
showButtons: hasFilters, showButtons: hasFilters,
onCancel: () => Get.back(), onCancel: () => Get.back(),
onSubmit: () { onSubmit: () {
final combinedFilter = { final combinedFilter = {
'uploadedBy': docController.selectedUploadedBy.value, 'uploadedByIds': docController.selectedUploadedBy.toList(),
'category': docController.selectedCategory.value, 'documentCategoryIds': docController.selectedCategory.toList(),
'type': docController.selectedType.value, 'documentTypeIds': docController.selectedType.toList(),
'tag': docController.selectedTag.value, 'documentTagIds': docController.selectedTag.toList(),
'isUploadedAt': docController.isUploadedAt.value,
'startDate': docController.startDate.value,
'endDate': docController.endDate.value,
if (docController.isVerified.value != null)
'isVerified': docController.isVerified.value,
}; };
docController.fetchDocuments( docController.fetchDocuments(
entityTypeId: entityTypeId, entityTypeId: entityTypeId,
entityId: entityId, entityId: entityId,
filter: combinedFilter.toString(), filter: jsonEncode(combinedFilter),
reset: true, reset: true,
); );
Get.back(); Get.back();
@ -67,32 +76,237 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
), ),
), ),
), ),
// --- Date Filter (Uploaded On / Updated On) ---
_buildField(
"Choose Date",
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Segmented Buttons
Obx(() {
return Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(24),
),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () =>
docController.isUploadedAt.value = true,
child: Container(
padding: const EdgeInsets.symmetric(
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,
),
),
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () => docController
.isUploadedAt.value = false,
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 10),
decoration: BoxDecoration(
color: !docController.isUploadedAt.value
? Colors.indigo.shade400
: Colors.transparent,
borderRadius:
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,
),
),
),
),
),
),
],
),
);
}),
MySpacing.height(12),
// Date Range
Row(
children: [
Expanded(
child: Obx(() {
return _dateButton(
label: docController.startDate.value == null
? 'From Date'
: DateTimeUtils.formatDate(
DateTime.parse(
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();
}
},
);
}),
),
],
),
],
),
),
MySpacing.height(8), MySpacing.height(8),
_buildDynamicField( _multiSelectField(
label: "Uploaded By", label: "Uploaded By",
items: filterData.uploadedBy, items: filterData.uploadedBy,
fallback: "Select Uploaded By", fallback: "Choose Uploaded By",
selectedValue: docController.selectedUploadedBy, selectedValues: docController.selectedUploadedBy,
), ),
_buildDynamicField( _multiSelectField(
label: "Category", label: "Category",
items: filterData.documentCategory, items: filterData.documentCategory,
fallback: "Select Category", fallback: "Choose Category",
selectedValue: docController.selectedCategory, selectedValues: docController.selectedCategory,
), ),
_buildDynamicField( _multiSelectField(
label: "Type", label: "Type",
items: filterData.documentType, items: filterData.documentType,
fallback: "Select Type", fallback: "Choose Type",
selectedValue: docController.selectedType, selectedValues: docController.selectedType,
), ),
_buildDynamicField( _multiSelectField(
label: "Tag", label: "Tag",
items: filterData.documentTag, items: filterData.documentTag,
fallback: "Select Tag", fallback: "Choose Tag",
selectedValue: docController.selectedTag, selectedValues: docController.selectedTag,
), ),
].where((w) => w != null).cast<Widget>().toList(),
// --- Document Status ---
_buildField(
" Document Status",
Obx(() {
return Container(
padding: MySpacing.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Radio<bool?>(
value: null,
groupValue: docController.isVerified.value,
onChanged: (val) =>
docController.isVerified.value = val,
activeColor:
Colors.indigo,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
MyText("All"),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Radio<bool?>(
value: true,
groupValue: docController.isVerified.value,
onChanged: (val) =>
docController.isVerified.value = val,
activeColor: Colors.indigo,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
MyText("Verified"),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Radio<bool?>(
value: false,
groupValue: docController.isVerified.value,
onChanged: (val) =>
docController.isVerified.value = val,
activeColor: Colors.indigo,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
MyText("Rejected"),
],
),
],
),
);
}),
),
],
) )
: Center( : Center(
child: Padding( child: Padding(
@ -110,70 +324,161 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
); );
} }
Widget? _buildDynamicField({ Widget _multiSelectField({
required String label, required String label,
required List<FilterItem> items, required List<FilterItem> items,
required String fallback, required String fallback,
required RxString selectedValue, required RxList<String> selectedValues,
}) { }) {
if (items.isEmpty) return null; if (items.isEmpty) return const SizedBox.shrink();
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.labelMedium(label), MyText.labelMedium(label),
MySpacing.height(8), MySpacing.height(8),
_popupSelector(items, fallback, selectedValue: selectedValue), 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: items.map(
(f) {
return PopupMenuItem<String>(
enabled: false, // prevent auto-close
child: StatefulBuilder(
builder: (context, setState) {
final isChecked = selectedValues.contains(f.id);
return CheckboxListTile(
dense: true,
value: isChecked,
contentPadding: EdgeInsets.zero,
controlAffinity:
ListTileControlAffinity.leading,
title: MyText(f.name),
// --- Styles ---
checkColor: Colors.white, // tick color
side: const BorderSide(
color: Colors.black,
width: 1.5), // border when unchecked
fillColor:
MaterialStateProperty.resolveWith<Color>(
(states) {
if (states
.contains(MaterialState.selected)) {
return Colors.indigo; // checked Indigo
}
return Colors.white; // unchecked White
},
),
onChanged: (val) {
if (val == true) {
selectedValues.add(f.id);
} else {
selectedValues.remove(f.id);
}
setState(() {}); // refresh UI
},
);
},
),
);
},
).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),
],
),
),
);
},
);
}),
MySpacing.height(16), MySpacing.height(16),
], ],
); );
} }
Widget _popupSelector( Widget _buildField(String label, Widget child) {
List<FilterItem> items, return Column(
String fallback, { crossAxisAlignment: CrossAxisAlignment.start,
required RxString selectedValue, children: [
}) { MyText.labelMedium(label),
return Obx(() { MySpacing.height(8),
final currentValue = _getCurrentName(selectedValue.value, items, fallback); child,
return PopupMenuButton<String>( MySpacing.height(8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ],
onSelected: (val) => selectedValue.value = val, );
itemBuilder: (context) => items
.map(
(f) => PopupMenuItem<String>(
value: f.id,
child: MyText(f.name),
),
)
.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),
],
),
),
);
});
} }
String _getCurrentName(String selectedId, List<FilterItem> list, String fallback) { Widget _dateButton({required String label, required VoidCallback onTap}) {
if (selectedId.isEmpty) return fallback; return GestureDetector(
final match = list.firstWhereOrNull((f) => f.id == selectedId); onTap: onTap,
return match?.name ?? fallback; 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

@ -1,33 +1,135 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
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/all_organization_controller.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_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class AddEmployeeBottomSheet extends StatefulWidget { class AddEmployeeBottomSheet extends StatefulWidget {
final Map<String, dynamic>? employeeData;
const AddEmployeeBottomSheet({super.key, this.employeeData});
@override @override
State<AddEmployeeBottomSheet> createState() => _AddEmployeeBottomSheetState(); State<AddEmployeeBottomSheet> createState() => _AddEmployeeBottomSheetState();
} }
class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
with UIMixin { with UIMixin {
final AddEmployeeController _controller = Get.put(AddEmployeeController()); late final AddEmployeeController _controller;
late final AllOrganizationController _organizationController;
// Local UI state
bool _hasApplicationAccess = false;
// Local read-only controllers to avoid recreating TextEditingController in build
late final TextEditingController _orgFieldController;
late final TextEditingController _joiningDateController;
late final TextEditingController _genderController;
late final TextEditingController _roleController;
@override
void initState() {
super.initState();
// Initialize text controllers
_orgFieldController = TextEditingController();
_joiningDateController = TextEditingController();
_genderController = TextEditingController();
_roleController = TextEditingController();
// Initialize AddEmployeeController
_controller = Get.put(AddEmployeeController(), tag: UniqueKey().toString());
// Pass organization ID from employeeData if available
final orgIdFromEmployee =
widget.employeeData?['organization_id'] as String?;
_organizationController = Get.put(
AllOrganizationController(passedOrgId: orgIdFromEmployee),
tag: UniqueKey().toString(),
);
// Keep _orgFieldController in sync with selected organization safely
ever(_organizationController.selectedOrganization, (_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_orgFieldController.text =
_organizationController.selectedOrganization.value?.name ??
'All Organizations';
});
});
// Prefill other fields if editing
if (widget.employeeData != null) {
_controller.editingEmployeeData = widget.employeeData;
_controller.prefillFields();
// Application access
_hasApplicationAccess =
widget.employeeData?['hasApplicationAccess'] ?? false;
// Email
final email = widget.employeeData?['email'];
if (email != null && email.toString().isNotEmpty) {
_controller.basicValidator.getController('email')?.text =
email.toString();
}
// Joining date
if (_controller.joiningDate != null) {
_joiningDateController.text =
DateFormat('dd MMM yyyy').format(_controller.joiningDate!);
}
// Gender
if (_controller.selectedGender != null) {
_genderController.text =
_controller.selectedGender!.name.capitalizeFirst ?? '';
}
// Prefill Role
_controller.fetchRoles().then((_) {
if (_controller.selectedRoleId != null) {
final roleName = _controller.roles.firstWhereOrNull(
(r) => r['id'] == _controller.selectedRoleId,
)?['name'];
if (roleName != null) {
_roleController.text = roleName;
}
_controller.update();
}
});
} else {
// Not editing: fetch roles
_controller.fetchRoles();
}
}
@override
void dispose() {
_orgFieldController.dispose();
_joiningDateController.dispose();
_genderController.dispose();
_roleController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GetBuilder<AddEmployeeController>( return GetBuilder<AddEmployeeController>(
init: _controller, init: _controller,
builder: (_) { builder: (_) {
// Keep org field in sync with controller selection
_orgFieldController.text = _organizationController.currentSelection;
return BaseBottomSheet( return BaseBottomSheet(
title: "Add Employee", title: widget.employeeData != null ? 'Edit Employee' : 'Add Employee',
onCancel: () => Navigator.pop(context), onCancel: () => Navigator.pop(context),
onSubmit: _handleSubmit, onSubmit: _handleSubmit,
child: Form( child: Form(
@ -35,11 +137,11 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_sectionLabel("Personal Info"), _sectionLabel('Personal Info'),
MySpacing.height(16), MySpacing.height(16),
_inputWithIcon( _inputWithIcon(
label: "First Name", label: 'First Name',
hint: "e.g., John", hint: 'e.g., John',
icon: Icons.person, icon: Icons.person,
controller: controller:
_controller.basicValidator.getController('first_name')!, _controller.basicValidator.getController('first_name')!,
@ -48,8 +150,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
), ),
MySpacing.height(16), MySpacing.height(16),
_inputWithIcon( _inputWithIcon(
label: "Last Name", label: 'Last Name',
hint: "e.g., Doe", hint: 'e.g., Doe',
icon: Icons.person_outline, icon: Icons.person_outline,
controller: controller:
_controller.basicValidator.getController('last_name')!, _controller.basicValidator.getController('last_name')!,
@ -57,37 +159,101 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
_controller.basicValidator.getValidation('last_name'), _controller.basicValidator.getValidation('last_name'),
), ),
MySpacing.height(16), MySpacing.height(16),
_sectionLabel("Joining Details"), _sectionLabel('Organization'),
MySpacing.height(8),
Obx(() {
return 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: _organizationController
.isLoadingOrganizations.value
? const SizedBox(
width: 24,
height: 24,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
),
),
),
);
}),
MySpacing.height(24),
_sectionLabel('Application Access'),
Row(
children: [
Checkbox(
value: _hasApplicationAccess,
onChanged: (val) {
setState(() => _hasApplicationAccess = val ?? false);
},
fillColor:
WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return Colors.indigo;
}
return Colors.white;
}),
side: WidgetStateBorderSide.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return BorderSide.none;
}
return const BorderSide(
color: Colors.black,
width: 2,
);
}),
checkColor: Colors.white,
),
MyText.bodyMedium(
'Has Application Access',
fontWeight: 600,
),
],
),
MySpacing.height(8),
_buildEmailField(),
MySpacing.height(12),
_sectionLabel('Joining Details'),
MySpacing.height(16), MySpacing.height(16),
_buildDatePickerField( _buildDatePickerField(
label: "Joining Date", label: 'Joining Date',
value: _controller.joiningDate != null controller: _joiningDateController,
? DateFormat("dd MMM yyyy") hint: 'Select Joining Date',
.format(_controller.joiningDate!)
: "",
hint: "Select Joining Date",
onTap: () => _pickJoiningDate(context), onTap: () => _pickJoiningDate(context),
), ),
MySpacing.height(16), MySpacing.height(16),
_sectionLabel("Contact Details"), _sectionLabel('Contact Details'),
MySpacing.height(16), MySpacing.height(16),
_buildPhoneInput(context), _buildPhoneInput(context),
MySpacing.height(24), MySpacing.height(24),
_sectionLabel("Other Details"), _sectionLabel('Other Details'),
MySpacing.height(16), MySpacing.height(16),
_buildDropdownField( _buildDropdownField(
label: "Gender", label: 'Gender',
value: _controller.selectedGender?.name.capitalizeFirst ?? '', controller: _genderController,
hint: "Select Gender", hint: 'Select Gender',
onTap: () => _showGenderPopup(context), onTap: () => _showGenderPopup(context),
), ),
MySpacing.height(16), MySpacing.height(16),
_buildDropdownField( _buildDropdownField(
label: "Role", label: 'Role',
value: _controller.roles.firstWhereOrNull((role) => controller: _roleController,
role['id'] == _controller.selectedRoleId)?['name'] ?? hint: 'Select Role',
"",
hint: "Select Role",
onTap: () => _showRolePopup(context), onTap: () => _showRolePopup(context),
), ),
], ],
@ -98,138 +264,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
); );
} }
// --- Common label with red star --- // UI Pieces
Widget _requiredLabel(String text) {
return Row(
children: [
MyText.labelMedium(text),
const SizedBox(width: 4),
const Text("*", style: TextStyle(color: Colors.red)),
],
);
}
// --- Date Picker field ---
Widget _buildDatePickerField({
required String label,
required String value,
required String hint,
required VoidCallback onTap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_requiredLabel(label),
MySpacing.height(8),
GestureDetector(
onTap: onTap,
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: TextEditingController(text: value),
validator: (val) {
if (val == null || val.trim().isEmpty) {
return "$label is required";
}
return null;
},
decoration: _inputDecoration(hint).copyWith(
suffixIcon: const Icon(Icons.calendar_today),
),
),
),
),
],
);
}
Future<void> _pickJoiningDate(BuildContext context) async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
_controller.setJoiningDate(picked);
_controller.update();
}
}
// --- Submit logic ---
Future<void> _handleSubmit() async {
// Run form validation first
final isValid =
_controller.basicValidator.formKey.currentState?.validate() ?? false;
if (!isValid) {
showAppSnackbar(
title: "Missing Fields",
message: "Please fill all required fields before submitting.",
type: SnackbarType.warning,
);
return;
}
// Additional check for dropdowns & joining date
if (_controller.joiningDate == null) {
showAppSnackbar(
title: "Missing Fields",
message: "Please select Joining Date.",
type: SnackbarType.warning,
);
return;
}
if (_controller.selectedGender == null) {
showAppSnackbar(
title: "Missing Fields",
message: "Please select Gender.",
type: SnackbarType.warning,
);
return;
}
if (_controller.selectedRoleId == null) {
showAppSnackbar(
title: "Missing Fields",
message: "Please select Role.",
type: SnackbarType.warning,
);
return;
}
// All validations passed Call API
final result = await _controller.createEmployees();
if (result != null && result['success'] == true) {
final employeeData = result['data'];
final employeeController = Get.find<EmployeesScreenController>();
final projectId = employeeController.selectedProjectId;
if (projectId == null) {
await employeeController.fetchAllEmployees();
} else {
await employeeController.fetchEmployeesByProject(projectId);
}
employeeController.update(['employee_screen_controller']);
// Reset form
_controller.basicValidator.getController("first_name")?.clear();
_controller.basicValidator.getController("last_name")?.clear();
_controller.basicValidator.getController("phone_number")?.clear();
_controller.selectedGender = null;
_controller.selectedRoleId = null;
_controller.joiningDate = null;
_controller.update();
Navigator.pop(context, employeeData);
}
}
// --- Section label widget ---
Widget _sectionLabel(String title) => Column( Widget _sectionLabel(String title) => Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -239,124 +275,16 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
], ],
); );
// --- Input field with icon --- Widget _requiredLabel(String text) {
Widget _inputWithIcon({ return Row(
required String label,
required String hint,
required IconData icon,
required TextEditingController controller,
required String? Function(String?)? validator,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_requiredLabel(label), MyText.labelMedium(text),
MySpacing.height(8), const SizedBox(width: 4),
TextFormField( const Text('*', style: TextStyle(color: Colors.red)),
controller: controller,
validator: (val) {
if (val == null || val.trim().isEmpty) {
return "$label is required";
}
return validator?.call(val);
},
decoration: _inputDecoration(hint).copyWith(
prefixIcon: Icon(icon, size: 20),
),
),
], ],
); );
} }
// --- Phone input ---
Widget _buildPhoneInput(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_requiredLabel("Phone Number"),
MySpacing.height(8),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
color: Colors.grey.shade100,
),
child: const Text("+91"),
),
MySpacing.width(12),
Expanded(
child: TextFormField(
controller:
_controller.basicValidator.getController('phone_number'),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "Phone Number is required";
}
if (value.trim().length != 10) {
return "Phone Number must be exactly 10 digits";
}
if (!RegExp(r'^\d{10}$').hasMatch(value.trim())) {
return "Enter a valid 10-digit number";
}
return null;
},
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
decoration: _inputDecoration("e.g., 9876543210").copyWith(
suffixIcon: IconButton(
icon: const Icon(Icons.contacts),
onPressed: () => _controller.pickContact(context),
),
),
),
),
],
),
],
);
}
// --- Dropdown (Gender/Role) ---
Widget _buildDropdownField({
required String label,
required String value,
required String hint,
required VoidCallback onTap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_requiredLabel(label),
MySpacing.height(8),
GestureDetector(
onTap: onTap,
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: TextEditingController(text: value),
validator: (val) {
if (val == null || val.trim().isEmpty) {
return "$label is required";
}
return null;
},
decoration: _inputDecoration(hint).copyWith(
suffixIcon: const Icon(Icons.expand_more),
),
),
),
),
],
);
}
// --- Common input decoration ---
InputDecoration _inputDecoration(String hint) { InputDecoration _inputDecoration(String hint) {
return InputDecoration( return InputDecoration(
hintText: hint, hintText: hint,
@ -379,40 +307,322 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
); );
} }
// --- Gender popup --- Widget _inputWithIcon({
void _showGenderPopup(BuildContext context) async { required String label,
final selected = await showMenu<Gender>( required String hint,
required IconData icon,
required TextEditingController controller,
required String? Function(String?)? validator,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_requiredLabel(label),
MySpacing.height(8),
TextFormField(
controller: controller,
validator: (val) {
if (val == null || val.trim().isEmpty) {
return '$label is required';
}
return validator?.call(val);
},
decoration: _inputDecoration(hint).copyWith(
prefixIcon: Icon(icon, size: 20),
),
),
],
);
}
Widget _buildEmailField() {
final emailController = _controller.basicValidator.getController('email') ??
TextEditingController();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
MyText.labelMedium('Email'),
const SizedBox(width: 4),
if (_hasApplicationAccess)
const Text('*', style: TextStyle(color: Colors.red)),
],
),
MySpacing.height(8),
TextFormField(
controller: emailController,
validator: (val) {
if (_hasApplicationAccess) {
if (val == null || val.trim().isEmpty) {
return 'Email is required for application users';
}
final email = val.trim();
if (!RegExp(r'^[\w\-\.]+@([\w\-]+\.)+[\w\-]{2,4}$')
.hasMatch(email)) {
return 'Enter a valid email address';
}
}
return null;
},
keyboardType: TextInputType.emailAddress,
decoration: _inputDecoration('e.g., john.doe@example.com').copyWith(),
),
],
);
}
Widget _buildDatePickerField({
required String label,
required TextEditingController controller,
required String hint,
required VoidCallback onTap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_requiredLabel(label),
MySpacing.height(8),
GestureDetector(
onTap: onTap,
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: controller,
validator: (val) {
if (val == null || val.trim().isEmpty) {
return '$label is required';
}
return null;
},
decoration: _inputDecoration(hint).copyWith(
suffixIcon: const Icon(Icons.calendar_today),
),
),
),
),
],
);
}
Widget _buildDropdownField({
required String label,
required TextEditingController controller,
required String hint,
required VoidCallback onTap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_requiredLabel(label),
MySpacing.height(8),
GestureDetector(
onTap: onTap,
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: controller,
validator: (val) {
if (val == null || val.trim().isEmpty) {
return '$label is required';
}
return null;
},
decoration: _inputDecoration(hint).copyWith(
suffixIcon: const Icon(Icons.expand_more),
),
),
),
),
],
);
}
Widget _buildPhoneInput(BuildContext context) {
final phoneController =
_controller.basicValidator.getController('phone_number');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_requiredLabel('Phone Number'),
MySpacing.height(8),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
color: Colors.grey.shade100,
),
child: const Text('+91'),
),
MySpacing.width(12),
Expanded(
child: TextFormField(
controller: phoneController,
validator: (value) {
final v = value?.trim() ?? '';
if (v.isEmpty) return 'Phone Number is required';
if (v.length != 10)
return 'Phone Number must be exactly 10 digits';
if (!RegExp(r'^\d{10}$').hasMatch(v)) {
return 'Enter a valid 10-digit number';
}
return null;
},
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
decoration: _inputDecoration('e.g., 9876543210').copyWith(
suffixIcon: IconButton(
icon: const Icon(Icons.contacts),
onPressed: () => _controller.pickContact(context),
),
),
),
),
],
),
],
);
}
// Actions
Future<void> _pickJoiningDate(BuildContext context) async {
final picked = await showDatePicker(
context: context, context: context,
position: _popupMenuPosition(context), initialDate: _controller.joiningDate ?? DateTime.now(),
items: Gender.values.map((gender) { firstDate: DateTime(2000),
return PopupMenuItem<Gender>( lastDate: DateTime.now(),
value: gender,
child: Text(gender.name.capitalizeFirst!),
);
}).toList(),
); );
if (selected != null) { if (picked != null) {
_controller.onGenderSelected(selected); _controller.setJoiningDate(picked);
_joiningDateController.text = DateFormat('dd MMM yyyy').format(picked);
_controller.update();
}
}
Future<void> _handleSubmit() async {
final isValid =
_controller.basicValidator.formKey.currentState?.validate() ?? false;
final selectedOrg = _organizationController.selectedOrganization.value;
if (!isValid ||
_controller.joiningDate == null ||
_controller.selectedGender == null ||
_controller.selectedRoleId == null ||
selectedOrg == null) {
showAppSnackbar(
title: 'Missing Fields',
message: 'Please complete all required fields.',
type: SnackbarType.warning,
);
return;
}
_controller.selectedOrganizationId = selectedOrg.id;
final result = await _controller.createOrUpdateEmployee(
email: _controller.basicValidator.getController('email')?.text.trim(),
hasApplicationAccess: _hasApplicationAccess,
);
if (result != null && result['success'] == true) {
final employeeController = Get.find<EmployeesScreenController>();
final projectId = employeeController.selectedProjectId;
if (projectId == null) {
await employeeController.fetchAllEmployees();
} else {
await employeeController.fetchEmployeesByProject(projectId);
}
employeeController.update(['employee_screen_controller']);
if (mounted) Navigator.pop(context, result['data']);
}
}
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 selectedOrgId = await showMenu<String>(
context: context,
position: _popupMenuPosition(context),
items: orgs
.map(
(org) => PopupMenuItem<String>(
value: org.id,
child: Text(org.name),
),
)
.toList(),
);
if (selectedOrgId != null) {
final chosenOrg = orgs.firstWhere((org) => org.id == selectedOrgId,
orElse: () => orgs.first);
_organizationController.selectOrganization(chosenOrg);
}
}
void _showGenderPopup(BuildContext context) async {
final selected = await showMenu<Gender>(
context: context,
position: _popupMenuPosition(context),
items: Gender.values
.map(
(gender) => PopupMenuItem<Gender>(
value: gender,
child: Text(gender.name.capitalizeFirst!),
),
)
.toList(),
);
if (selected != null) {
_controller.onGenderSelected(selected);
_genderController.text = selected.name.capitalizeFirst ?? '';
_controller.update(); _controller.update();
} }
} }
// --- Role popup ---
void _showRolePopup(BuildContext context) async { void _showRolePopup(BuildContext context) async {
final selected = await showMenu<String>( final selected = await showMenu<String>(
context: context, context: context,
position: _popupMenuPosition(context), position: _popupMenuPosition(context),
items: _controller.roles.map((role) { items: _controller.roles
return PopupMenuItem<String>( .map(
value: role['id'], (role) => PopupMenuItem<String>(
child: Text(role['name']), value: role['id'],
); child: Text(role['name']),
}).toList(), ),
)
.toList(),
); );
if (selected != null) { if (selected != null) {
_controller.onRoleSelected(selected); _controller.onRoleSelected(selected);
final roleName = _controller.roles
.firstWhereOrNull((r) => r['id'] == selected)?['name'] ??
'';
_roleController.text = roleName;
_controller.update(); _controller.update();
} }
} }

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

@ -1,51 +1,65 @@
class GlobalProjectModel { class GlobalProjectModel {
final String id; final String id;
final String name; final String name;
final String projectAddress; final String projectAddress;
final String contactPerson; final String contactPerson;
final DateTime startDate; final DateTime? startDate;
final DateTime endDate; final DateTime? endDate;
final int teamSize; final int teamSize;
final String projectStatusId; final String projectStatusId;
final String? tenantId; final String? tenantId;
GlobalProjectModel({ GlobalProjectModel({
required this.id, required this.id,
required this.name, required this.name,
required this.projectAddress, required this.projectAddress,
required this.contactPerson, required this.contactPerson,
required this.startDate, this.startDate,
required this.endDate, this.endDate,
required this.teamSize, required this.teamSize,
required this.projectStatusId, required this.projectStatusId,
this.tenantId, this.tenantId,
}); });
factory GlobalProjectModel.fromJson(Map<String, dynamic> json) { factory GlobalProjectModel.fromJson(Map<String, dynamic> json) {
return GlobalProjectModel( return GlobalProjectModel(
id: json['id'] ?? '', id: json['id'] ?? '',
name: json['name'] ?? '', name: json['name'] ?? '',
projectAddress: json['projectAddress'] ?? '', projectAddress: json['projectAddress'] ?? '',
contactPerson: json['contactPerson'] ?? '', contactPerson: json['contactPerson'] ?? '',
startDate: DateTime.parse(json['startDate']), startDate: _parseDate(json['startDate']),
endDate: DateTime.parse(json['endDate']), endDate: _parseDate(json['endDate']),
teamSize: json['teamSize'] ?? 0, // SAFER teamSize: json['teamSize'] is int
projectStatusId: json['projectStatusId'] ?? '', ? json['teamSize']
tenantId: json['tenantId'], : int.tryParse(json['teamSize']?.toString() ?? '0') ?? 0,
); projectStatusId: json['projectStatusId'] ?? '',
tenantId: json['tenantId'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'projectAddress': projectAddress,
'contactPerson': contactPerson,
'startDate': startDate?.toIso8601String(),
'endDate': endDate?.toIso8601String(),
'teamSize': teamSize,
'projectStatusId': projectStatusId,
'tenantId': tenantId,
};
}
static DateTime? _parseDate(dynamic value) {
if (value == null || value.toString().trim().isEmpty) {
return null;
}
try {
return DateTime.parse(value.toString());
} catch (e) {
print('⚠️ Failed to parse date "$value": $e');
return null;
}
}
} }
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'projectAddress': projectAddress,
'contactPerson': contactPerson,
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
'teamSize': teamSize,
'projectStatusId': projectStatusId,
'tenantId': tenantId,
};
}
}

View File

@ -3,8 +3,8 @@ class ProjectModel {
final String name; final String name;
final String projectAddress; final String projectAddress;
final String contactPerson; final String contactPerson;
final DateTime startDate; final DateTime? startDate;
final DateTime endDate; final DateTime? endDate;
final int teamSize; final int teamSize;
final double completedWork; final double completedWork;
final double plannedWork; final double plannedWork;
@ -16,8 +16,8 @@ class ProjectModel {
required this.name, required this.name,
required this.projectAddress, required this.projectAddress,
required this.contactPerson, required this.contactPerson,
required this.startDate, this.startDate,
required this.endDate, this.endDate,
required this.teamSize, required this.teamSize,
required this.completedWork, required this.completedWork,
required this.plannedWork, required this.plannedWork,
@ -25,36 +25,30 @@ class ProjectModel {
this.tenantId, this.tenantId,
}); });
// Factory method to create an instance of ProjectModel from a JSON object
factory ProjectModel.fromJson(Map<String, dynamic> json) { factory ProjectModel.fromJson(Map<String, dynamic> json) {
return ProjectModel( return ProjectModel(
id: json['id'], id: json['id']?.toString() ?? '',
name: json['name'], name: json['name']?.toString() ?? '',
projectAddress: json['projectAddress'], projectAddress: json['projectAddress']?.toString() ?? '',
contactPerson: json['contactPerson'], contactPerson: json['contactPerson']?.toString() ?? '',
startDate: DateTime.parse(json['startDate']), startDate: _parseDate(json['startDate']),
endDate: DateTime.parse(json['endDate']), endDate: _parseDate(json['endDate']),
teamSize: json['teamSize'], teamSize: _parseInt(json['teamSize']),
completedWork: json['completedWork'] != null completedWork: _parseDouble(json['completedWork']),
? (json['completedWork'] as num).toDouble() plannedWork: _parseDouble(json['plannedWork']),
: 0.0, projectStatusId: json['projectStatusId']?.toString() ?? '',
plannedWork: json['plannedWork'] != null tenantId: json['tenantId']?.toString(),
? (json['plannedWork'] as num).toDouble()
: 0.0,
projectStatusId: json['projectStatusId'],
tenantId: json['tenantId'],
); );
} }
// Method to convert the ProjectModel instance back to a JSON object
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'id': id, 'id': id,
'name': name, 'name': name,
'projectAddress': projectAddress, 'projectAddress': projectAddress,
'contactPerson': contactPerson, 'contactPerson': contactPerson,
'startDate': startDate.toIso8601String(), 'startDate': startDate?.toIso8601String(),
'endDate': endDate.toIso8601String(), 'endDate': endDate?.toIso8601String(),
'teamSize': teamSize, 'teamSize': teamSize,
'completedWork': completedWork, 'completedWork': completedWork,
'plannedWork': plannedWork, 'plannedWork': plannedWork,
@ -62,4 +56,30 @@ class ProjectModel {
'tenantId': tenantId, 'tenantId': tenantId,
}; };
} }
// ---------- Helpers ----------
static DateTime? _parseDate(dynamic value) {
if (value == null || value.toString().trim().isEmpty) {
return null;
}
try {
return DateTime.parse(value.toString());
} catch (e) {
print('⚠️ Failed to parse date: $value');
return null;
}
}
static int _parseInt(dynamic value) {
if (value == null) return 0;
if (value is int) return value;
return int.tryParse(value.toString()) ?? 0;
}
static double _parseDouble(dynamic value) {
if (value == null) return 0.0;
if (value is num) return value.toDouble();
return double.tryParse(value.toString()) ?? 0.0;
}
} }

View File

@ -0,0 +1,109 @@
class Tenant {
final String id;
final String name;
final String email;
final String? domainName;
final String contactName;
final String contactNumber;
final String? logoImage;
final String? organizationSize;
final Industry? industry;
final TenantStatus? tenantStatus;
Tenant({
required this.id,
required this.name,
required this.email,
this.domainName,
required this.contactName,
required this.contactNumber,
this.logoImage,
this.organizationSize,
this.industry,
this.tenantStatus,
});
factory Tenant.fromJson(Map<String, dynamic> json) {
return Tenant(
id: json['id'] ?? '',
name: json['name'] ?? '',
email: json['email'] ?? '',
domainName: json['domainName'] as String?,
contactName: json['contactName'] ?? '',
contactNumber: json['contactNumber'] ?? '',
logoImage: json['logoImage'] is String ? json['logoImage'] : null,
organizationSize: json['organizationSize'] is String
? json['organizationSize']
: null,
industry: json['industry'] != null
? Industry.fromJson(json['industry'])
: null,
tenantStatus: json['tenantStatus'] != null
? TenantStatus.fromJson(json['tenantStatus'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'domainName': domainName,
'contactName': contactName,
'contactNumber': contactNumber,
'logoImage': logoImage,
'organizationSize': organizationSize,
'industry': industry?.toJson(),
'tenantStatus': tenantStatus?.toJson(),
};
}
}
class Industry {
final String id;
final String name;
Industry({
required this.id,
required this.name,
});
factory Industry.fromJson(Map<String, dynamic> json) {
return Industry(
id: json['id'] ?? '',
name: json['name'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
};
}
}
class TenantStatus {
final String id;
final String name;
TenantStatus({
required this.id,
required this.name,
});
factory TenantStatus.fromJson(Map<String, dynamic> json) {
return TenantStatus(
id: json['id'] ?? '',
name: json['name'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
};
}
}

View File

@ -0,0 +1,78 @@
class ServiceListResponse {
final bool success;
final String message;
final List<Service> data;
final dynamic errors;
final int statusCode;
final String timestamp;
ServiceListResponse({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory ServiceListResponse.fromJson(Map<String, dynamic> json) {
return ServiceListResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: (json['data'] as List<dynamic>?)
?.map((e) => Service.fromJson(e))
.toList() ??
[],
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: json['timestamp'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data.map((e) => e.toJson()).toList(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp,
};
}
}
class Service {
final String id;
final String name;
final String description;
final bool isSystem;
final bool isActive;
Service({
required this.id,
required this.name,
required this.description,
required this.isSystem,
required this.isActive,
});
factory Service.fromJson(Map<String, dynamic> json) {
return Service(
id: json['id'] ?? '',
name: json['name'] ?? '',
description: json['description'] ?? '',
isSystem: json['isSystem'] ?? false,
isActive: json['isActive'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'isSystem': isSystem,
'isActive': isActive,
};
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/tenant_service.dart';
import 'package:marco/view/auth/forgot_password_screen.dart'; import 'package:marco/view/auth/forgot_password_screen.dart';
import 'package:marco/view/auth/login_screen.dart'; import 'package:marco/view/auth/login_screen.dart';
import 'package:marco/view/auth/register_account_screen.dart'; import 'package:marco/view/auth/register_account_screen.dart';
@ -11,7 +12,7 @@ import 'package:marco/view/error_pages/error_500_screen.dart';
import 'package:marco/view/dashboard/dashboard_screen.dart'; import 'package:marco/view/dashboard/dashboard_screen.dart';
import 'package:marco/view/Attendence/attendance_screen.dart'; import 'package:marco/view/Attendence/attendance_screen.dart';
import 'package:marco/view/taskPlanning/daily_task_planning.dart'; import 'package:marco/view/taskPlanning/daily_task_planning.dart';
import 'package:marco/view/taskPlanning/daily_progress.dart'; import 'package:marco/view/taskPlanning/daily_progress_report.dart';
import 'package:marco/view/employees/employees_screen.dart'; import 'package:marco/view/employees/employees_screen.dart';
import 'package:marco/view/auth/login_option_screen.dart'; import 'package:marco/view/auth/login_option_screen.dart';
import 'package:marco/view/auth/mpin_screen.dart'; import 'package:marco/view/auth/mpin_screen.dart';
@ -19,13 +20,21 @@ import 'package:marco/view/auth/mpin_auth_screen.dart';
import 'package:marco/view/directory/directory_main_screen.dart'; import 'package:marco/view/directory/directory_main_screen.dart';
import 'package:marco/view/expense/expense_screen.dart'; import 'package:marco/view/expense/expense_screen.dart';
import 'package:marco/view/document/user_document_screen.dart'; import 'package:marco/view/document/user_document_screen.dart';
import 'package:marco/view/tenant/tenant_selection_screen.dart';
class AuthMiddleware extends GetMiddleware { class AuthMiddleware extends GetMiddleware {
@override @override
RouteSettings? redirect(String? route) { RouteSettings? redirect(String? route) {
return AuthService.isLoggedIn if (!AuthService.isLoggedIn) {
? null if (route != '/auth/login-option') {
: RouteSettings(name: '/auth/login-option'); return const RouteSettings(name: '/auth/login-option');
}
} else if (!TenantService.isTenantSelected) {
if (route != '/select-tenant') {
return const RouteSettings(name: '/select-tenant');
}
}
return null;
} }
} }
@ -36,10 +45,14 @@ getPageRoute() {
page: () => DashboardScreen(), page: () => DashboardScreen(),
middlewares: [AuthMiddleware()]), middlewares: [AuthMiddleware()]),
GetPage( GetPage(
name: '/home', name: '/dashboard',
page: () => DashboardScreen(), // or your actual home screen page: () => DashboardScreen(), // or your actual home screen
middlewares: [AuthMiddleware()], middlewares: [AuthMiddleware()],
), ),
GetPage(
name: '/select-tenant',
page: () => const TenantSelectionScreen(),
middlewares: [AuthMiddleware()]),
// Dashboard // Dashboard
GetPage( GetPage(
@ -67,12 +80,12 @@ getPageRoute() {
name: '/dashboard/directory-main-page', name: '/dashboard/directory-main-page',
page: () => DirectoryMainScreen(), page: () => DirectoryMainScreen(),
middlewares: [AuthMiddleware()]), middlewares: [AuthMiddleware()]),
// Expense // Expense
GetPage( GetPage(
name: '/dashboard/expense-main-page', name: '/dashboard/expense-main-page',
page: () => ExpenseMainScreen(), page: () => ExpenseMainScreen(),
middlewares: [AuthMiddleware()]), middlewares: [AuthMiddleware()]),
// Documents // Documents
GetPage( GetPage(
name: '/dashboard/document-main-page', name: '/dashboard/document-main-page',
page: () => UserDocumentsPage(), page: () => UserDocumentsPage(),

View File

@ -11,7 +11,6 @@ import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/model/attendance/log_details_view.dart'; import 'package:marco/model/attendance/log_details_view.dart';
import 'package:marco/model/attendance/attendence_action_button.dart'; import 'package:marco/model/attendance/attendence_action_button.dart';
import 'package:marco/helpers/utils/attendance_actions.dart'; import 'package:marco/helpers/utils/attendance_actions.dart';
import 'package:marco/helpers/services/app_logger.dart';
class AttendanceLogsTab extends StatefulWidget { class AttendanceLogsTab extends StatefulWidget {
final AttendanceController controller; final AttendanceController controller;
@ -94,16 +93,6 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
} else { } else {
priority = 5; priority = 5;
} }
// Use AppLogger instead of print
logSafe(
"[AttendanceLogs] Priority calculated "
"name=${employee.name}, activity=${employee.activity}, "
"checkIn=${employee.checkIn}, checkOut=${employee.checkOut}, "
"buttonText=$text, priority=$priority",
level: LogLevel.debug,
);
return priority; return priority;
} }
@ -116,7 +105,7 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
final showPendingOnly = widget.controller.showPendingOnly.value; final showPendingOnly = widget.controller.showPendingOnly.value;
final filteredLogs = showPendingOnly final filteredLogs = showPendingOnly
? allLogs ? allLogs
.where((emp) => emp.activity == 1 || emp.activity == 2) .where((emp) => emp.activity == 1 )
.toList() .toList()
: allLogs; : allLogs;

View File

@ -47,6 +47,7 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
Future<void> _loadData(String projectId) async { Future<void> _loadData(String projectId) async {
try { try {
attendanceController.selectedTab = 'todaysAttendance';
await attendanceController.loadAttendanceData(projectId); await attendanceController.loadAttendanceData(projectId);
attendanceController.update(['attendance_dashboard_controller']); attendanceController.update(['attendance_dashboard_controller']);
} catch (e) { } catch (e) {
@ -56,7 +57,24 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
Future<void> _refreshData() async { Future<void> _refreshData() async {
final projectId = projectController.selectedProjectId.value; final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) await _loadData(projectId); if (projectId.isEmpty) return;
// Call only the relevant API for current tab
switch (selectedTab) {
case 'todaysAttendance':
await attendanceController.fetchTodaysAttendance(projectId);
break;
case 'attendanceLogs':
await attendanceController.fetchAttendanceLogs(
projectId,
dateFrom: attendanceController.startDateAttendance,
dateTo: attendanceController.endDateAttendance,
);
break;
case 'regularizationRequests':
await attendanceController.fetchRegularizationLogs(projectId);
break;
}
} }
Widget _buildAppBar() { Widget _buildAppBar() {
@ -195,15 +213,26 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
final selectedProjectId = final selectedProjectId =
projectController.selectedProjectId.value; projectController.selectedProjectId.value;
final selectedView = result['selectedTab'] as String?; final selectedView = result['selectedTab'] as String?;
final selectedOrgId =
result['selectedOrganization'] as String?;
if (selectedOrgId != null) {
attendanceController.selectedOrganization =
attendanceController.organizations
.firstWhere((o) => o.id == selectedOrgId);
}
if (selectedProjectId.isNotEmpty) { if (selectedProjectId.isNotEmpty) {
try { try {
await attendanceController await attendanceController.fetchTodaysAttendance(
.fetchEmployeesByProject(selectedProjectId); selectedProjectId,
await attendanceController );
.fetchAttendanceLogs(selectedProjectId); await attendanceController.fetchAttendanceLogs(
await attendanceController selectedProjectId,
.fetchRegularizationLogs(selectedProjectId); );
await attendanceController.fetchRegularizationLogs(
selectedProjectId,
);
await attendanceController await attendanceController
.fetchProjectData(selectedProjectId); .fetchProjectData(selectedProjectId);
} catch (_) {} } catch (_) {}
@ -214,6 +243,11 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
if (selectedView != null && selectedView != selectedTab) { if (selectedView != null && selectedView != selectedTab) {
setState(() => selectedTab = selectedView); setState(() => selectedTab = selectedView);
attendanceController.selectedTab = selectedView;
if (selectedProjectId.isNotEmpty) {
await attendanceController
.fetchProjectData(selectedProjectId);
}
} }
} }
}, },

View File

@ -123,20 +123,24 @@ class _EmailLoginFormState extends State<EmailLoginForm> with UIMixin {
), ),
MySpacing.height(28), MySpacing.height(28),
Center( Center(
child: MyButton.rounded( child: Obx(() {
onPressed: controller.onLogin, final isLoading = controller.isLoading.value;
elevation: 2, return MyButton.rounded(
padding: MySpacing.xy(80, 16), onPressed: isLoading
borderRadiusAll: 10, ? null
backgroundColor: contentTheme.brandRed, : controller.onLogin,
child: MyText.labelLarge( elevation: 2,
'Login', padding: MySpacing.xy(80, 16),
fontWeight: 700, borderRadiusAll: 10,
color: contentTheme.onPrimary, backgroundColor: contentTheme.brandRed,
), child: MyText.labelLarge(
), isLoading ? 'Logging in...' : 'Login',
fontWeight: 700,
color: contentTheme.onPrimary,
),
);
}),
), ),
], ],
), ),
); );

View File

@ -37,7 +37,7 @@ class _LoginScreenState extends State<LoginScreen> with UIMixin {
builder: (_) { builder: (_) {
return Obx(() { return Obx(() {
if (controller.isLoading.value) { if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator()); return const Center(child: LinearProgressIndicator());
} }
return Form( return Form(

View File

@ -13,7 +13,6 @@ import 'package:marco/helpers/widgets/dashbaord/attendance_overview_chart.dart';
import 'package:marco/helpers/widgets/dashbaord/project_progress_chart.dart'; import 'package:marco/helpers/widgets/dashbaord/project_progress_chart.dart';
import 'package:marco/view/layouts/layout.dart'; import 'package:marco/view/layouts/layout.dart';
import 'package:marco/controller/dynamicMenu/dynamic_menu_controller.dart'; import 'package:marco/controller/dynamicMenu/dynamic_menu_controller.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/helpers/widgets/dashbaord/dashboard_overview_widgets.dart'; import 'package:marco/helpers/widgets/dashbaord/dashboard_overview_widgets.dart';
class DashboardScreen extends StatefulWidget { class DashboardScreen extends StatefulWidget {
@ -85,13 +84,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
/// Project Progress Chart Section /// Project Progress Chart Section
Widget _buildProjectProgressChartSection() { Widget _buildProjectProgressChartSection() {
return Obx(() { return Obx(() {
if (dashboardController.isProjectLoading.value) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: SkeletonLoaders.chartSkeletonLoader(),
);
}
if (dashboardController.projectChartData.isEmpty) { if (dashboardController.projectChartData.isEmpty) {
return const Padding( return const Padding(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
@ -102,7 +94,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
} }
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
child: SizedBox( child: SizedBox(
height: 400, height: 400,
child: ProjectProgressChart( child: ProjectProgressChart(
@ -116,15 +108,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
/// Attendance Chart Section /// Attendance Chart Section
Widget _buildAttendanceChartSection() { Widget _buildAttendanceChartSection() {
return Obx(() { return Obx(() {
if (menuController.isLoading.value) {
// Show Skeleton Loader Instead of CircularProgressIndicator
return Padding(
padding: const EdgeInsets.all(8.0),
child: SkeletonLoaders
.chartSkeletonLoader(), // <-- using the skeleton we built
);
}
final isAttendanceAllowed = menuController.isMenuAllowed("Attendance"); final isAttendanceAllowed = menuController.isMenuAllowed("Attendance");
if (!isAttendanceAllowed) { if (!isAttendanceAllowed) {
@ -141,7 +124,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
child: IgnorePointer( child: IgnorePointer(
ignoring: !isProjectSelected, ignoring: !isProjectSelected,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
child: SizedBox( child: SizedBox(
height: 400, height: 400,
child: AttendanceDashboardChart(), child: AttendanceDashboardChart(),
@ -198,7 +181,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
width: width, width: width,
height: 100, height: 100,
paddingAll: 5, paddingAll: 5,
borderRadiusAll: 10, borderRadiusAll: 5,
border: Border.all(color: Colors.grey.withOpacity(0.15)), border: Border.all(color: Colors.grey.withOpacity(0.15)),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -219,14 +202,14 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
); );
} }
/// Dashboard Statistics Section with Compact Cards /// Dashboard Statistics Section
Widget _buildDashboardStats(BuildContext context) { Widget _buildDashboardStats(BuildContext context) {
return Obx(() { return Obx(() {
if (menuController.isLoading.value) { if (menuController.isLoading.value) {
return _buildLoadingSkeleton(context); return _buildLoadingSkeleton(context);
} }
if (menuController.hasError.value && menuController.menuItems.isEmpty) {
// Only show error if there are no menus at all if (menuController.hasError.value || menuController.menuItems.isEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Center( child: Center(
@ -238,6 +221,10 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
); );
} }
final projectController = Get.find<ProjectController>();
final isProjectSelected = projectController.selectedProject != null;
// Keep previous stat items (icons, title, routes)
final stats = [ final stats = [
_StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success, _StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success,
DashboardScreen.attendanceRoute), DashboardScreen.attendanceRoute),
@ -255,39 +242,50 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
DashboardScreen.documentMainPageRoute), DashboardScreen.documentMainPageRoute),
]; ];
final projectController = Get.find<ProjectController>(); // Safe menu check function to avoid exceptions
final isProjectSelected = projectController.selectedProject != null; bool _isMenuAllowed(String menuTitle) {
try {
return menuController.menuItems.isNotEmpty
? menuController.isMenuAllowed(menuTitle)
: false;
} catch (e) {
return false;
}
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (!isProjectSelected) _buildNoProjectMessage(), if (!isProjectSelected) _buildNoProjectMessage(),
Wrap( LayoutBuilder(
spacing: 6, builder: (context, constraints) {
runSpacing: 6, int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 8);
children: stats double cardWidth =
.where((stat) { (constraints.maxWidth - (crossAxisCount - 1) * 6) /
// Always allow Documents crossAxisCount;
if (stat.title == "Documents") return true;
// For all other menus, respect sidebar permissions return Wrap(
return menuController.isMenuAllowed(stat.title); spacing: 6,
}) runSpacing: 6,
.map((stat) => _buildStatCard(stat, isProjectSelected)) alignment: WrapAlignment.start,
.toList(), children: stats
.where((stat) => _isMenuAllowed(stat.title))
.map((stat) =>
_buildStatCard(stat, isProjectSelected, cardWidth))
.toList(),
);
},
), ),
], ],
); );
}); });
} }
/// Stat Card (Compact with wrapping text) /// Stat Card (Compact + Small)
/// Stat Card (Compact with wrapping text) Widget _buildStatCard(
Widget _buildStatCard(_StatItem statItem, bool isProjectSelected) { _StatItem statItem, bool isProjectSelected, double width) {
const double cardWidth = 80; const double cardHeight = 60;
const double cardHeight = 70;
// Attendance should always be enabled
final bool isEnabled = statItem.title == "Attendance" || isProjectSelected; final bool isEnabled = statItem.title == "Attendance" || isProjectSelected;
return Opacity( return Opacity(
@ -296,30 +294,28 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
ignoring: !isEnabled, ignoring: !isEnabled,
child: InkWell( child: InkWell(
onTap: () => _handleStatCardTap(statItem, isEnabled), onTap: () => _handleStatCardTap(statItem, isEnabled),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(5),
child: MyCard.bordered( child: MyCard.bordered(
width: cardWidth, width: width,
height: cardHeight, height: cardHeight,
paddingAll: 4, paddingAll: 4,
borderRadiusAll: 8, borderRadiusAll: 5,
border: Border.all(color: Colors.grey.withOpacity(0.15)), border: Border.all(color: Colors.grey.withOpacity(0.15)),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
_buildStatCardIconCompact(statItem), _buildStatCardIconCompact(statItem, size: 12),
MySpacing.height(4), MySpacing.height(4),
Expanded( Flexible(
child: Center( child: Text(
child: Text( statItem.title,
statItem.title, textAlign: TextAlign.center,
textAlign: TextAlign.center, style: const TextStyle(
style: const TextStyle( fontSize: 8,
fontSize: 10, overflow: TextOverflow.visible,
overflow: TextOverflow.visible,
),
maxLines: 2,
softWrap: true,
), ),
maxLines: 2,
softWrap: true,
), ),
), ),
], ],
@ -330,6 +326,19 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
); );
} }
/// Compact Icon (smaller)
Widget _buildStatCardIconCompact(_StatItem statItem, {double size = 12}) {
return MyContainer.rounded(
paddingAll: 4,
color: statItem.color.withOpacity(0.1),
child: Icon(
statItem.icon,
size: size,
color: statItem.color,
),
);
}
/// Handle Tap /// Handle Tap
void _handleStatCardTap(_StatItem statItem, bool isEnabled) { void _handleStatCardTap(_StatItem statItem, bool isEnabled) {
if (!isEnabled) { if (!isEnabled) {
@ -346,21 +355,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
Get.toNamed(statItem.route); Get.toNamed(statItem.route);
} }
} }
/// Compact Icon
Widget _buildStatCardIconCompact(_StatItem statItem) {
return MyContainer.rounded(
paddingAll: 6,
color: statItem.color.withOpacity(0.1),
child: Icon(
statItem.icon,
size: 14,
color: statItem.color,
),
);
}
/// Handle Tap
} }
class _StatItem { class _StatItem {

View File

@ -16,6 +16,8 @@ import 'package:marco/model/directory/add_comment_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/model/directory/add_contact_bottom_sheet.dart'; import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/model/directory/directory_comment_model.dart';
// HELPER: Delta to HTML conversion // HELPER: Delta to HTML conversion
String _convertDeltaToHtml(dynamic delta) { String _convertDeltaToHtml(dynamic delta) {
@ -63,6 +65,7 @@ String _convertDeltaToHtml(dynamic delta) {
class ContactDetailScreen extends StatefulWidget { class ContactDetailScreen extends StatefulWidget {
final ContactModel contact; final ContactModel contact;
const ContactDetailScreen({super.key, required this.contact}); const ContactDetailScreen({super.key, required this.contact});
@override @override
State<ContactDetailScreen> createState() => _ContactDetailScreenState(); State<ContactDetailScreen> createState() => _ContactDetailScreenState();
} }
@ -70,16 +73,28 @@ class ContactDetailScreen extends StatefulWidget {
class _ContactDetailScreenState extends State<ContactDetailScreen> { class _ContactDetailScreenState extends State<ContactDetailScreen> {
late final DirectoryController directoryController; late final DirectoryController directoryController;
late final ProjectController projectController; late final ProjectController projectController;
late ContactModel contact;
late Rx<ContactModel> contactRx;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
directoryController = Get.find<DirectoryController>(); directoryController = Get.find<DirectoryController>();
projectController = Get.find<ProjectController>(); projectController = Get.find<ProjectController>();
contact = widget.contact; contactRx = widget.contact.obs;
WidgetsBinding.instance.addPostFrameCallback((_) {
directoryController.fetchCommentsForContact(contact.id); WidgetsBinding.instance.addPostFrameCallback((_) async {
await directoryController.fetchCommentsForContact(contactRx.value.id,
active: true);
await directoryController.fetchCommentsForContact(contactRx.value.id,
active: false);
});
// Listen to controller's allContacts and update contact if changed
ever(directoryController.allContacts, (_) {
final updated = directoryController.allContacts
.firstWhereOrNull((c) => c.id == contactRx.value.id);
if (updated != null) contactRx.value = updated;
}); });
} }
@ -94,12 +109,12 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSubHeader(), Obx(() => _buildSubHeader(contactRx.value)),
const Divider(height: 1, thickness: 0.5, color: Colors.grey), const Divider(height: 1, thickness: 0.5, color: Colors.grey),
Expanded( Expanded(
child: TabBarView(children: [ child: TabBarView(children: [
_buildDetailsTab(), Obx(() => _buildDetailsTab(contactRx.value)),
_buildCommentsTab(context), _buildCommentsTab(),
]), ]),
), ),
], ],
@ -135,9 +150,9 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
MyText.titleLarge('Contact Profile', MyText.titleLarge('Contact Profile',
fontWeight: 700, color: Colors.black), fontWeight: 700, color: Colors.black),
MySpacing.height(2), MySpacing.height(2),
GetBuilder<ProjectController>( GetBuilder<ProjectController>(builder: (p) {
builder: (p) => ProjectLabel(p.selectedProject?.name), return ProjectLabel(p.selectedProject?.name);
), }),
], ],
), ),
), ),
@ -147,7 +162,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
); );
} }
Widget _buildSubHeader() { Widget _buildSubHeader(ContactModel contact) {
final firstName = contact.name.split(" ").first; final firstName = contact.name.split(" ").first;
final lastName = final lastName =
contact.name.split(" ").length > 1 ? contact.name.split(" ").last : ""; contact.name.split(" ").length > 1 ? contact.name.split(" ").last : "";
@ -159,10 +174,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
children: [ children: [
Row(children: [ Row(children: [
Avatar( Avatar(
firstName: firstName, firstName: firstName,
lastName: lastName, lastName: lastName,
size: 35, size: 35,
backgroundColor: Colors.indigo), ),
MySpacing.width(12), MySpacing.width(12),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -188,7 +203,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
), ),
tabs: const [ tabs: const [
Tab(text: "Details"), Tab(text: "Details"),
Tab(text: "Comments"), Tab(text: "Notes"),
], ],
), ),
], ],
@ -196,7 +211,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
); );
} }
Widget _buildDetailsTab() { Widget _buildDetailsTab(ContactModel contact) {
final tags = contact.tags.map((e) => e.name).join(", "); final tags = contact.tags.map((e) => e.name).join(", ");
final bucketNames = contact.bucketIds final bucketNames = contact.bucketIds
.map((id) => directoryController.contactBuckets .map((id) => directoryController.contactBuckets
@ -249,7 +264,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MySpacing.height(12), MySpacing.height(12),
// BASIC INFO CARD
_infoCard("Basic Info", [ _infoCard("Basic Info", [
multiRows( multiRows(
items: items:
@ -273,20 +287,17 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
), ),
_iconInfoRow(Icons.location_on, "Address", contact.address), _iconInfoRow(Icons.location_on, "Address", contact.address),
]), ]),
// ORGANIZATION CARD
_infoCard("Organization", [ _infoCard("Organization", [
_iconInfoRow( _iconInfoRow(
Icons.business, "Organization", contact.organization), Icons.business, "Organization", contact.organization),
_iconInfoRow(Icons.category, "Category", category), _iconInfoRow(Icons.category, "Category", category),
]), ]),
// META INFO CARD
_infoCard("Meta Info", [ _infoCard("Meta Info", [
_iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"), _iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"),
_iconInfoRow(Icons.folder_shared, "Contact Buckets", _iconInfoRow(Icons.folder_shared, "Contact Buckets",
bucketNames.isNotEmpty ? bucketNames : "-"), bucketNames.isNotEmpty ? bucketNames : "-"),
_iconInfoRow(Icons.work_outline, "Projects", projectNames), _iconInfoRow(Icons.work_outline, "Projects", projectNames),
]), ]),
// DESCRIPTION CARD
_infoCard("Description", [ _infoCard("Description", [
MySpacing.height(6), MySpacing.height(6),
Align( Align(
@ -318,7 +329,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
final updated = directoryController.allContacts final updated = directoryController.allContacts
.firstWhereOrNull((c) => c.id == contact.id); .firstWhereOrNull((c) => c.id == contact.id);
if (updated != null) { if (updated != null) {
setState(() => contact = updated); contactRx.value = updated;
} }
} }
}, },
@ -331,41 +342,26 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
); );
} }
Widget _buildCommentsTab(BuildContext context) { Widget _buildCommentsTab() {
return Obx(() { return Obx(() {
final contactId = contact.id; final contactId = contactRx.value.id;
if (!directoryController.contactCommentsMap.containsKey(contactId)) { final comments = directoryController.combinedComments(contactId);
return const Center(child: CircularProgressIndicator());
}
final comments = directoryController
.getCommentsForContact(contactId)
.reversed
.toList();
final editingId = directoryController.editingCommentId.value; final editingId = directoryController.editingCommentId.value;
return Stack( return Stack(
children: [ children: [
MyRefreshIndicator( comments.isEmpty
onRefresh: () async { ? Center(
await directoryController.fetchCommentsForContact(contactId); child: MyText.bodyLarge("No notes yet.", color: Colors.grey),
}, )
child: comments.isEmpty : MyRefreshIndicator(
? ListView( onRefresh: () async {
physics: const AlwaysScrollableScrollPhysics(), await directoryController.fetchCommentsForContact(contactId,
children: [ active: true);
SizedBox( await directoryController.fetchCommentsForContact(contactId,
height: MediaQuery.of(context).size.height * 0.6, active: false);
child: Center( },
child: MyText.bodyLarge( child: Padding(
"No comments yet.",
color: Colors.grey,
),
),
),
],
)
: Padding(
padding: MySpacing.xy(12, 12), padding: MySpacing.xy(12, 12),
child: ListView.separated( child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
@ -373,13 +369,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
itemCount: comments.length, itemCount: comments.length,
separatorBuilder: (_, __) => MySpacing.height(14), separatorBuilder: (_, __) => MySpacing.height(14),
itemBuilder: (_, index) => _buildCommentItem( itemBuilder: (_, index) => _buildCommentItem(
comments[index], comments[index], editingId, contactId),
editingId,
contact.id,
),
), ),
), ),
), ),
if (editingId == null) if (editingId == null)
Positioned( Positioned(
bottom: 20, bottom: 20,
@ -392,15 +385,15 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
isScrollControlled: true, isScrollControlled: true,
); );
if (result == true) { if (result == true) {
await directoryController await directoryController.fetchCommentsForContact(contactId,
.fetchCommentsForContact(contactId); active: true);
await directoryController.fetchCommentsForContact(contactId,
active: false);
} }
}, },
icon: const Icon(Icons.add_comment, color: Colors.white), icon: const Icon(Icons.add_comment, color: Colors.white),
label: const Text( label: const Text("Add Note",
"Add Comment", style: TextStyle(color: Colors.white)),
style: TextStyle(color: Colors.white),
),
), ),
), ),
], ],
@ -408,11 +401,13 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
}); });
} }
Widget _buildCommentItem(comment, editingId, contactId) { Widget _buildCommentItem(
DirectoryComment comment, String? editingId, String contactId) {
final isEditing = editingId == comment.id; final isEditing = editingId == comment.id;
final initials = comment.createdBy.firstName.isNotEmpty final initials = comment.createdBy.firstName.isNotEmpty
? comment.createdBy.firstName[0].toUpperCase() ? comment.createdBy.firstName[0].toUpperCase()
: "?"; : "?";
final decodedDelta = HtmlToDelta().convert(comment.note); final decodedDelta = HtmlToDelta().convert(comment.note);
final quillController = isEditing final quillController = isEditing
? quill.QuillController( ? quill.QuillController(
@ -421,88 +416,190 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
) )
: null; : null;
return AnimatedContainer( final isInactive = !comment.isActive;
duration: const Duration(milliseconds: 300),
padding: MySpacing.xy(8, 7), return Container(
decoration: BoxDecoration( margin: const EdgeInsets.symmetric(vertical: 6),
color: isEditing ? Colors.indigo[50] : Colors.white, padding: const EdgeInsets.all(12),
borderRadius: BorderRadius.circular(12), decoration: BoxDecoration(
border: Border.all( color: Colors.white,
color: isEditing ? Colors.indigo : Colors.grey.shade300, borderRadius: BorderRadius.circular(14),
width: 1.2, border: Border.all(color: Colors.grey.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
), ),
boxShadow: const [ child: Column(
BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2)) crossAxisAlignment: CrossAxisAlignment.start,
], children: [
), // 🧑 Header
child: Column( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Header Row Avatar(
Row( firstName: initials,
crossAxisAlignment: CrossAxisAlignment.start, lastName: '',
children: [ size: 40,
Avatar(firstName: initials, lastName: '', size: 36), ),
MySpacing.width(12), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.bodyMedium("By: ${comment.createdBy.firstName}", Text(
fontWeight: 600, color: Colors.indigo[800]), "${comment.createdBy.firstName} ${comment.createdBy.lastName}",
MySpacing.height(4), style: TextStyle(
MyText.bodySmall( fontWeight: FontWeight.w700,
DateTimeUtils.convertUtcToLocal( fontSize: 15,
comment.createdAt.toString(), color: isInactive ? Colors.grey : Colors.black87,
format: 'dd MMM yyyy, hh:mm a', fontStyle:
isInactive ? FontStyle.italic : FontStyle.normal,
),
overflow: TextOverflow.ellipsis,
), ),
color: Colors.grey[600], const SizedBox(height: 2),
), if (comment.createdBy.jobRoleName.isNotEmpty)
], Text(
comment.createdBy.jobRoleName,
style: TextStyle(
fontSize: 13,
color:
isInactive ? Colors.grey : Colors.indigo[600],
fontWeight: FontWeight.w500,
fontStyle: isInactive
? FontStyle.italic
: FontStyle.normal,
),
),
const SizedBox(height: 2),
Text(
DateTimeUtils.convertUtcToLocal(
comment.createdAt.toString(),
format: 'dd MMM yyyy, hh:mm a',
),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontStyle:
isInactive ? FontStyle.italic : FontStyle.normal,
),
),
],
),
), ),
),
IconButton( // Action buttons
icon: Icon( if (!isInactive)
isEditing ? Icons.close : Icons.edit, Row(
size: 20, mainAxisSize: MainAxisSize.min,
color: Colors.indigo, children: [
), IconButton(
onPressed: () { icon: const Icon(Icons.edit_outlined,
directoryController.editingCommentId.value = size: 18, color: Colors.indigo),
isEditing ? null : comment.id; tooltip: "Edit",
splashRadius: 18,
onPressed: () {
directoryController.editingCommentId.value =
isEditing ? null : comment.id;
},
),
IconButton(
icon: const Icon(Icons.delete_outline,
size: 18, color: Colors.red),
tooltip: "Delete",
splashRadius: 18,
onPressed: () async {
await Get.dialog(
ConfirmDialog(
title: "Delete Note",
message:
"Are you sure you want to delete this note?",
confirmText: "Delete",
confirmColor: Colors.red,
icon: Icons.delete_forever,
onConfirm: () async {
await directoryController.deleteComment(
comment.id, contactId);
},
),
);
},
),
],
)
else
IconButton(
icon: const Icon(Icons.restore,
size: 18, color: Colors.green),
tooltip: "Restore",
splashRadius: 18,
onPressed: () async {
await Get.dialog(
ConfirmDialog(
title: "Restore Note",
message:
"Are you sure you want to restore this note?",
confirmText: "Restore",
confirmColor: Colors.green,
icon: Icons.restore,
onConfirm: () async {
await directoryController.restoreComment(
comment.id, contactId);
},
),
);
},
),
],
),
const SizedBox(height: 8),
// 📝 Comment Content
if (isEditing && quillController != null)
CommentEditorCard(
controller: quillController,
onCancel: () =>
directoryController.editingCommentId.value = null,
onSave: (ctrl) async {
final delta = ctrl.document.toDelta();
final htmlOutput = _convertDeltaToHtml(delta);
final updated = comment.copyWith(note: htmlOutput);
await directoryController.updateComment(updated);
await directoryController.fetchCommentsForContact(contactId,
active: true);
await directoryController.fetchCommentsForContact(contactId,
active: false);
directoryController.editingCommentId.value = null;
},
)
else
html.Html(
data: comment.note,
style: {
"body": html.Style(
margin: html.Margins.zero,
padding: html.HtmlPaddings.zero,
fontSize: html.FontSize(14),
color: isInactive ? Colors.grey : Colors.black87,
fontStyle: isInactive ? FontStyle.italic : FontStyle.normal,
),
"p": html.Style(
margin: html.Margins.only(bottom: 6),
lineHeight: const html.LineHeight(1.4),
),
"strong": html.Style(
fontWeight: FontWeight.w700,
color: isInactive ? Colors.grey : Colors.black87,
),
}, },
), ),
], ],
), ));
// Comment Content
if (isEditing && quillController != null)
CommentEditorCard(
controller: quillController,
onCancel: () => directoryController.editingCommentId.value = null,
onSave: (ctrl) async {
final delta = ctrl.document.toDelta();
final htmlOutput = _convertDeltaToHtml(delta);
final updated = comment.copyWith(note: htmlOutput);
await directoryController.updateComment(updated);
await directoryController.fetchCommentsForContact(contactId);
directoryController.editingCommentId.value = null;
},
)
else
html.Html(
data: comment.note,
style: {
"body": html.Style(
margin: html.Margins.zero,
padding: html.HtmlPaddings.zero,
fontSize: html.FontSize.medium,
color: Colors.black87,
),
},
),
],
),
);
} }
Widget _iconInfoRow( Widget _iconInfoRow(

View File

@ -10,16 +10,36 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/view/directory/directory_view.dart'; import 'package:marco/view/directory/directory_view.dart';
import 'package:marco/view/directory/notes_view.dart'; import 'package:marco/view/directory/notes_view.dart';
class DirectoryMainScreen extends StatelessWidget { class DirectoryMainScreen extends StatefulWidget {
DirectoryMainScreen({super.key}); const DirectoryMainScreen({super.key});
@override
State<DirectoryMainScreen> createState() => _DirectoryMainScreenState();
}
class _DirectoryMainScreenState extends State<DirectoryMainScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final DirectoryController controller = Get.put(DirectoryController()); final DirectoryController controller = Get.put(DirectoryController());
final NotesController notesController = Get.put(NotesController()); final NotesController notesController = Get.put(NotesController());
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: const Color(0xFFF5F5F5),
appBar: PreferredSize( appBar: PreferredSize(
preferredSize: const Size.fromHeight(72), preferredSize: const Size.fromHeight(72),
child: AppBar( child: AppBar(
@ -79,116 +99,34 @@ class DirectoryMainScreen extends StatelessWidget {
), ),
), ),
), ),
body: SafeArea( body: Column(
child: Column( children: [
children: [ // ---------------- TabBar ----------------
// Toggle between Directory and Notes Container(
Padding( color: Colors.white,
padding: MySpacing.fromLTRB(8, 12, 8, 5), child: TabBar(
child: Obx(() { controller: _tabController,
final isNotesView = controller.isNotesView.value; labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
return Container( indicatorColor: Colors.red,
padding: EdgeInsets.all(2), tabs: const [
decoration: BoxDecoration( Tab(text: "Directory"),
color: const Color(0xFFF0F0F0), Tab(text: "Notes"),
borderRadius: BorderRadius.circular(10), ],
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => controller.isNotesView.value = false,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(
vertical: 6, horizontal: 10),
decoration: BoxDecoration(
color: !isNotesView
? Colors.red
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.contacts,
size: 16,
color: !isNotesView
? Colors.white
: Colors.grey),
const SizedBox(width: 6),
Text(
'Directory',
style: TextStyle(
color: !isNotesView
? Colors.white
: Colors.grey,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () => controller.isNotesView.value = true,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(
vertical: 6, horizontal: 10),
decoration: BoxDecoration(
color:
isNotesView ? Colors.red : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.notes,
size: 16,
color: isNotesView
? Colors.white
: Colors.grey),
const SizedBox(width: 6),
Text(
'Notes',
style: TextStyle(
color: isNotesView
? Colors.white
: Colors.grey,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
),
),
),
],
),
);
}),
), ),
),
// Main View // ---------------- TabBarView ----------------
Expanded( Expanded(
child: Obx(() => child: TabBarView(
controller.isNotesView.value ? NotesView() : DirectoryView()), controller: _tabController,
children: [
DirectoryView(),
NotesView(),
],
), ),
], ),
), ],
), ),
); );
} }

View File

@ -17,6 +17,7 @@ import 'package:marco/view/directory/contact_detail_screen.dart';
import 'package:marco/view/directory/manage_bucket_screen.dart'; import 'package:marco/view/directory/manage_bucket_screen.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
class DirectoryView extends StatefulWidget { class DirectoryView extends StatefulWidget {
@override @override
@ -89,7 +90,7 @@ class _DirectoryViewState extends State<DirectoryView> {
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(5),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -114,7 +115,7 @@ class _DirectoryViewState extends State<DirectoryView> {
backgroundColor: Colors.grey[300], backgroundColor: Colors.grey[300],
foregroundColor: Colors.black, foregroundColor: Colors.black,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
), ),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
), ),
@ -129,7 +130,7 @@ class _DirectoryViewState extends State<DirectoryView> {
backgroundColor: Colors.indigo, backgroundColor: Colors.indigo,
foregroundColor: Colors.white, foregroundColor: Colors.white,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
), ),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
), ),
@ -144,18 +145,42 @@ class _DirectoryViewState extends State<DirectoryView> {
); );
} }
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.perm_contact_cal, size: 60, color: Colors.grey),
MySpacing.height(18),
MyText.titleMedium(
'No matching contacts found.',
fontWeight: 600,
color: Colors.grey,
),
MySpacing.height(10),
MyText.bodySmall(
'Try adjusting your filters or refresh to reload.',
color: Colors.grey,
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.grey[100],
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton.extended(
heroTag: 'createContact', heroTag: 'createContact',
backgroundColor: Colors.red, backgroundColor: Colors.red,
onPressed: _handleCreateContact, onPressed: _handleCreateContact,
child: const Icon(Icons.person_add_alt_1, color: Colors.white), icon: const Icon(Icons.person_add_alt_1, color: Colors.white),
label: const Text("Add Contact", style: TextStyle(color: Colors.white)),
), ),
body: Column( body: Column(
children: [ children: [
// Search + Filter + More menu
Padding( Padding(
padding: MySpacing.xy(8, 8), padding: MySpacing.xy(8, 8),
child: Row( child: Row(
@ -177,9 +202,8 @@ class _DirectoryViewState extends State<DirectoryView> {
suffixIcon: ValueListenableBuilder<TextEditingValue>( suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: searchController, valueListenable: searchController,
builder: (context, value, _) { builder: (context, value, _) {
if (value.text.isEmpty) { if (value.text.isEmpty)
return const SizedBox.shrink(); return const SizedBox.shrink();
}
return IconButton( return IconButton(
icon: const Icon(Icons.clear, icon: const Icon(Icons.clear,
size: 20, color: Colors.grey), size: 20, color: Colors.grey),
@ -195,11 +219,11 @@ class _DirectoryViewState extends State<DirectoryView> {
filled: true, filled: true,
fillColor: Colors.white, fillColor: Colors.white,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
), ),
@ -217,7 +241,7 @@ class _DirectoryViewState extends State<DirectoryView> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
), ),
child: IconButton( child: IconButton(
icon: Icon(Icons.tune, icon: Icon(Icons.tune,
@ -231,7 +255,7 @@ class _DirectoryViewState extends State<DirectoryView> {
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical( borderRadius: BorderRadius.vertical(
top: Radius.circular(20)), top: Radius.circular(5)),
), ),
builder: (_) => builder: (_) =>
const DirectoryFilterBottomSheet(), const DirectoryFilterBottomSheet(),
@ -262,15 +286,14 @@ class _DirectoryViewState extends State<DirectoryView> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
), ),
child: PopupMenuButton<int>( child: PopupMenuButton<int>(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
icon: const Icon(Icons.more_vert, icon: const Icon(Icons.more_vert,
size: 20, color: Colors.black87), size: 20, color: Colors.black87),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5)),
),
itemBuilder: (context) { itemBuilder: (context) {
List<PopupMenuEntry<int>> menuItems = []; List<PopupMenuEntry<int>> menuItems = [];
@ -279,17 +302,13 @@ class _DirectoryViewState extends State<DirectoryView> {
const PopupMenuItem<int>( const PopupMenuItem<int>(
enabled: false, enabled: false,
height: 30, height: 30,
child: Text( child: Text("Actions",
"Actions", style: TextStyle(
style: TextStyle( fontWeight: FontWeight.bold,
fontWeight: FontWeight.bold, color: Colors.grey)),
color: Colors.grey,
),
),
), ),
); );
// Conditionally show Create Bucket option
if (permissionController if (permissionController
.hasPermission(Permissions.directoryAdmin) || .hasPermission(Permissions.directoryAdmin) ||
permissionController permissionController
@ -355,13 +374,10 @@ class _DirectoryViewState extends State<DirectoryView> {
const PopupMenuItem<int>( const PopupMenuItem<int>(
enabled: false, enabled: false,
height: 30, height: 30,
child: Text( child: Text("Preferences",
"Preferences", style: TextStyle(
style: TextStyle( fontWeight: FontWeight.bold,
fontWeight: FontWeight.bold, color: Colors.grey)),
color: Colors.grey,
),
),
), ),
); );
@ -375,7 +391,8 @@ class _DirectoryViewState extends State<DirectoryView> {
const Icon(Icons.visibility_off_outlined, const Icon(Icons.visibility_off_outlined,
size: 20, color: Colors.black87), size: 20, color: Colors.black87),
const SizedBox(width: 10), const SizedBox(width: 10),
const Expanded(child: Text('Show Inactive')), const Expanded(
child: Text('Show Deleted Contacts')),
Switch.adaptive( Switch.adaptive(
value: !controller.isActive.value, value: !controller.isActive.value,
activeColor: Colors.indigo, activeColor: Colors.indigo,
@ -397,231 +414,347 @@ class _DirectoryViewState extends State<DirectoryView> {
], ],
), ),
), ),
// Contact List
Expanded( Expanded(
child: Obx(() { child: Obx(() {
return MyRefreshIndicator( return MyRefreshIndicator(
onRefresh: _refreshDirectory, onRefresh: _refreshDirectory,
backgroundColor: Colors.indigo, backgroundColor: Colors.indigo,
color: Colors.white, color: Colors.white,
child: controller.isLoading.value child: controller.isLoading.value
? ListView.separated( ? ListView.separated(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
itemCount: 10, itemCount: 10,
separatorBuilder: (_, __) => MySpacing.height(12), separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, __) => itemBuilder: (_, __) =>
SkeletonLoaders.contactSkeletonCard(), SkeletonLoaders.contactSkeletonCard(),
) )
: controller.filteredContacts.isEmpty : controller.filteredContacts.isEmpty
? ListView( ? _buildEmptyState()
physics: const AlwaysScrollableScrollPhysics(), : ListView.separated(
children: [ physics: const AlwaysScrollableScrollPhysics(),
SizedBox( padding: MySpacing.only(
height: left: 8, right: 8, top: 4, bottom: 80),
MediaQuery.of(context).size.height * 0.6, itemCount: controller.filteredContacts.length,
child: Center( separatorBuilder: (_, __) => MySpacing.height(12),
child: Column( itemBuilder: (_, index) {
mainAxisAlignment: MainAxisAlignment.center, final contact =
children: [ controller.filteredContacts[index];
const Icon(Icons.contact_page_outlined, final isDeleted = !controller
size: 60, color: Colors.grey), .isActive.value; // mark deleted contacts
const SizedBox(height: 12), final nameParts =
MyText.bodyMedium('No contacts found.', contact.name.trim().split(" ");
fontWeight: 500), final firstName = nameParts.first;
], final lastName =
), nameParts.length > 1 ? nameParts.last : "";
), final tags = contact.tags
), .map((tag) => tag.name)
], .toList();
)
: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.only(
left: 8, right: 8, top: 4, bottom: 80),
itemCount: controller.filteredContacts.length,
separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, index) {
final contact =
controller.filteredContacts[index];
final nameParts = contact.name.trim().split(" ");
final firstName = nameParts.first;
final lastName =
nameParts.length > 1 ? nameParts.last : "";
final tags =
contact.tags.map((tag) => tag.name).toList();
return InkWell( return Card(
onTap: () { shape: RoundedRectangleBorder(
Get.to(() => borderRadius: BorderRadius.circular(5),
ContactDetailScreen(contact: contact)); ),
}, elevation: 3,
child: Padding( shadowColor: Colors.grey.withOpacity(0.3),
padding: color: Colors.white,
const EdgeInsets.fromLTRB(12, 10, 12, 0), child: InkWell(
child: Row( borderRadius: BorderRadius.circular(5),
crossAxisAlignment: onTap: isDeleted
CrossAxisAlignment.start, ? null
children: [ : () => Get.to(() =>
Avatar( ContactDetailScreen(
firstName: firstName, contact: contact)),
lastName: lastName, child: Padding(
size: 35), padding: const EdgeInsets.all(12),
MySpacing.width(12), child: Row(
Expanded( crossAxisAlignment:
child: Column( CrossAxisAlignment.start,
crossAxisAlignment: children: [
CrossAxisAlignment.start, // Avatar
children: [ Avatar(
MyText.titleSmall(contact.name, firstName: firstName,
fontWeight: 600, lastName: lastName,
overflow: size: 40,
TextOverflow.ellipsis), backgroundColor: isDeleted
MyText.bodySmall( ? Colors.grey.shade400
contact.organization, : null,
color: Colors.grey[700], ),
overflow: MySpacing.width(12),
TextOverflow.ellipsis), // Contact Info
MySpacing.height(8), Expanded(
if (contact child: Column(
.contactEmails.isNotEmpty) crossAxisAlignment:
GestureDetector( CrossAxisAlignment.start,
onTap: () => children: [
LauncherUtils.launchEmail( MyText.titleSmall(
contact contact.name,
.contactEmails fontWeight: 600,
.first overflow:
.emailAddress), TextOverflow.ellipsis,
onLongPress: () => LauncherUtils color: isDeleted
.copyToClipboard( ? Colors.grey
contact.contactEmails.first : Colors.black87,
.emailAddress,
typeLabel: 'Email',
), ),
child: Padding( MyText.bodySmall(
padding: contact.organization,
const EdgeInsets.only( color: isDeleted
bottom: 4), ? Colors.grey
child: Row( : Colors.grey[700],
children: [ overflow:
const Icon( TextOverflow.ellipsis,
Icons.email_outlined,
size: 16,
color: Colors.indigo),
MySpacing.width(4),
Expanded(
child:
MyText.labelSmall(
contact
.contactEmails
.first
.emailAddress,
overflow: TextOverflow
.ellipsis,
color: Colors.indigo,
decoration:
TextDecoration
.underline,
),
),
],
),
), ),
), MySpacing.height(6),
if (contact if (contact
.contactPhones.isNotEmpty) .contactEmails.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only( padding:
bottom: 8, top: 4), const EdgeInsets.only(
child: Row( bottom: 4),
children: [ child: GestureDetector(
Expanded( onTap: isDeleted
child: GestureDetector( ? null
onTap: () => LauncherUtils : () => LauncherUtils
.launchPhone(contact .launchEmail(contact
.contactPhones .contactEmails
.first .first
.phoneNumber), .emailAddress),
onLongPress: () => onLongPress: isDeleted
LauncherUtils ? null
.copyToClipboard( : () => LauncherUtils
contact .copyToClipboard(
.contactPhones
.first
.phoneNumber,
typeLabel: 'Phone',
),
child: Row(
children: [
const Icon(
Icons
.phone_outlined,
size: 16,
color: Colors
.indigo),
MySpacing.width(4),
Expanded(
child: MyText
.labelSmall(
contact contact
.contactPhones .contactEmails
.first .first
.phoneNumber, .emailAddress,
overflow: typeLabel:
TextOverflow 'Email',
.ellipsis, ),
color: Colors child: Row(
.indigo, children: [
decoration: Icon(
TextDecoration Icons
.underline, .email_outlined,
size: 16,
color: isDeleted
? Colors.grey
: Colors
.indigo),
MySpacing.width(4),
Expanded(
child: MyText
.labelSmall(
contact
.contactEmails
.first
.emailAddress,
overflow:
TextOverflow
.ellipsis,
color: isDeleted
? Colors.grey
: Colors
.indigo,
decoration:
TextDecoration
.underline,
),
),
],
),
),
),
if (contact
.contactPhones.isNotEmpty)
Padding(
padding:
const EdgeInsets.only(
bottom: 8, top: 4),
child: Row(
children: [
Expanded(
child:
GestureDetector(
onTap: isDeleted
? null
: () => LauncherUtils
.launchPhone(contact
.contactPhones
.first
.phoneNumber),
onLongPress:
isDeleted
? null
: () =>
LauncherUtils
.copyToClipboard(
contact
.contactPhones
.first
.phoneNumber,
typeLabel:
'Phone',
),
child: Row(
children: [
Icon(
Icons
.phone_outlined,
size: 16,
color: isDeleted
? Colors
.grey
: Colors
.indigo),
MySpacing.width(
4),
Expanded(
child: MyText
.labelSmall(
contact
.contactPhones
.first
.phoneNumber,
overflow:
TextOverflow
.ellipsis,
color: isDeleted
? Colors
.grey
: Colors
.indigo,
decoration:
TextDecoration
.underline,
),
),
],
),
),
),
MySpacing.width(8),
GestureDetector(
onTap: isDeleted
? null
: () => LauncherUtils
.launchWhatsApp(contact
.contactPhones
.first
.phoneNumber),
child: FaIcon(
FontAwesomeIcons
.whatsapp,
color: isDeleted
? Colors.grey
: Colors
.green,
size: 25),
),
],
),
),
if (tags.isNotEmpty)
Padding(
padding:
const EdgeInsets.only(
top: 0),
child: Wrap(
spacing: 6,
runSpacing: 2,
children: tags
.map(
(tag) => Chip(
label: Text(tag),
backgroundColor:
Colors.indigo
.shade50,
labelStyle: TextStyle(
color: isDeleted
? Colors
.grey
: Colors
.indigo,
fontSize: 12),
visualDensity:
VisualDensity
.compact,
shape:
RoundedRectangleBorder(
borderRadius:
BorderRadius
.circular(
5),
), ),
), ),
], )
), .toList(),
),
), ),
MySpacing.width(8), ),
GestureDetector( ],
onTap: () => LauncherUtils ),
.launchWhatsApp( ),
contact // Actions Column (Arrow + Icons)
.contactPhones Column(
.first children: [
.phoneNumber), IconButton(
child: const FaIcon( icon: Icon(
FontAwesomeIcons isDeleted
.whatsapp, ? Icons.restore
color: Colors.green, : Icons.delete,
size: 16, color: isDeleted
), ? Colors.green
), : Colors.redAccent,
], size: 20,
), ),
onPressed: () async {
await Get.dialog(
ConfirmDialog(
title: isDeleted
? "Restore Contact"
: "Delete Contact",
message: isDeleted
? "Are you sure you want to restore this contact?"
: "Are you sure you want to delete this contact?",
confirmText: isDeleted
? "Restore"
: "Delete",
confirmColor: isDeleted
? Colors.green
: Colors.redAccent,
icon: isDeleted
? Icons.restore
: Icons
.delete_forever,
onConfirm: () async {
if (isDeleted) {
await controller
.restoreContact(
contact.id);
} else {
await controller
.deleteContact(
contact.id);
}
},
),
barrierDismissible: false,
);
},
), ),
if (tags.isNotEmpty) ...[ const SizedBox(height: 4),
MySpacing.height(2), Icon(
MyText.labelSmall(tags.join(', '), Icons.arrow_forward_ios,
color: Colors.grey[500], color: Colors.grey,
maxLines: 1, size: 20,
overflow: )
TextOverflow.ellipsis),
], ],
], ),
),
),
Column(
children: [
const Icon(Icons.arrow_forward_ios,
color: Colors.grey, size: 16),
MySpacing.height(8),
], ],
), ),
], ),
), ),
), );
); }));
},
),
);
}), }),
) )
], ],

View File

@ -3,14 +3,15 @@ import 'package:get/get.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:flutter_quill/flutter_quill.dart' as quill;
import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart'; import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart';
import 'package:flutter_html/flutter_html.dart' as html; import 'package:flutter_html/flutter_html.dart' as html;
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/controller/directory/notes_controller.dart'; import 'package:marco/controller/directory/notes_controller.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/avatar.dart'; import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart'; import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
class NotesView extends StatelessWidget { class NotesView extends StatelessWidget {
final NotesController controller = Get.find(); final NotesController controller = Get.find();
@ -67,15 +68,36 @@ class NotesView extends StatelessWidget {
} }
if (inList) buffer.write('</ul>'); if (inList) buffer.write('</ul>');
return buffer.toString(); return buffer.toString();
} }
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.perm_contact_cal, size: 60, color: Colors.grey),
MySpacing.height(18),
MyText.titleMedium(
'No matching notes found.',
fontWeight: 600,
color: Colors.grey,
),
MySpacing.height(10),
MyText.bodySmall(
'Try adjusting your filters or refresh to reload.',
color: Colors.grey,
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
/// 🔍 Search + Refresh (Top Row) /// 🔍 Search Field
Padding( Padding(
padding: MySpacing.xy(8, 8), padding: MySpacing.xy(8, 8),
child: Row( child: Row(
@ -94,22 +116,22 @@ class NotesView extends StatelessWidget {
filled: true, filled: true,
fillColor: Colors.white, fillColor: Colors.white,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
), ),
), ),
), ),
), ),
], ],
), ),
), ),
/// 📄 Notes List View /// 📄 Notes List
Expanded( Expanded(
child: Obx(() { child: Obx(() {
if (controller.isLoading.value) { if (controller.isLoading.value) {
@ -121,25 +143,17 @@ class NotesView extends StatelessWidget {
if (notes.isEmpty) { if (notes.isEmpty) {
return MyRefreshIndicator( return MyRefreshIndicator(
onRefresh: _refreshNotes, onRefresh: _refreshNotes,
child: ListView( child: LayoutBuilder(
physics: const AlwaysScrollableScrollPhysics(), builder: (context, constraints) {
children: [ return SingleChildScrollView(
SizedBox( physics: const AlwaysScrollableScrollPhysics(),
height: MediaQuery.of(context).size.height * 0.6, child: ConstrainedBox(
child: Center( constraints:
child: Column( BoxConstraints(minHeight: constraints.maxHeight),
mainAxisAlignment: MainAxisAlignment.center, child: Center(child: _buildEmptyState()),
children: [
const Icon(Icons.note_alt_outlined,
size: 60, color: Colors.grey),
const SizedBox(height: 12),
MyText.bodyMedium('No notes found.',
fontWeight: 500),
],
),
), ),
), );
], },
), ),
); );
} }
@ -187,89 +201,187 @@ class NotesView extends StatelessWidget {
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
padding: MySpacing.xy(12, 12), padding: MySpacing.xy(12, 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isEditing ? Colors.indigo[50] : Colors.white, color: isEditing
? Colors.indigo[50]
: note.isActive
? Colors.white
: Colors.grey.shade100,
border: Border.all( border: Border.all(
color: color: note.isActive
isEditing ? Colors.indigo : Colors.grey.shade300, ? (isEditing
? Colors.indigo
: Colors.grey.shade300)
: Colors.grey.shade400,
width: 1.1, width: 1.1,
), ),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
boxShadow: const [ boxShadow: const [
BoxShadow( BoxShadow(
color: Colors.black12, color: Colors.black12,
blurRadius: 4, blurRadius: 4,
offset: Offset(0, 2)), offset: Offset(0, 2),
),
], ],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
/// Header Row // Header & Note content (fade them if inactive)
Row( AnimatedOpacity(
crossAxisAlignment: CrossAxisAlignment.start, duration: const Duration(milliseconds: 200),
children: [ opacity: note.isActive ? 1.0 : 0.6,
Avatar( child: IgnorePointer(
firstName: initials, lastName: '', size: 40), ignoring: !note.isActive,
MySpacing.width(12), child: Column(
Expanded( crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, Row(
children: [ crossAxisAlignment:
MyText.titleSmall( CrossAxisAlignment.start,
"${note.contactName} (${note.organizationName})", children: [
fontWeight: 600, Avatar(
overflow: TextOverflow.ellipsis, firstName: initials,
color: Colors.indigo[800], lastName: '',
size: 40,
backgroundColor: note.isActive
? null
: Colors.grey.shade400,
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
MyText.titleSmall(
"${note.contactName} (${note.organizationName})",
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: note.isActive
? Colors.indigo[800]
: Colors.grey,
),
MyText.bodySmall(
"by ${note.createdBy.firstName}$createdDate, $createdTime",
color: note.isActive
? Colors.grey[600]
: Colors.grey,
),
],
),
),
],
),
MySpacing.height(12),
if (isEditing && quillController != null)
CommentEditorCard(
controller: quillController,
onCancel: () =>
controller.editingNoteId.value = null,
onSave: (quillCtrl) async {
final delta =
quillCtrl.document.toDelta();
final htmlOutput =
_convertDeltaToHtml(delta);
final updated =
note.copyWith(note: htmlOutput);
await controller.updateNote(updated);
controller.editingNoteId.value = null;
},
)
else
html.Html(
data: note.note,
style: {
"body": html.Style(
margin: html.Margins.zero,
padding: html.HtmlPaddings.zero,
fontSize: html.FontSize.medium,
color: note.isActive
? Colors.black87
: Colors.grey,
),
},
), ),
MyText.bodySmall( ],
"by ${note.createdBy.firstName}$createdDate, $createdTime",
color: Colors.grey[600],
),
],
),
), ),
IconButton( ),
icon: Icon(
isEditing ? Icons.close : Icons.edit,
color: Colors.indigo,
size: 20,
),
onPressed: () {
controller.editingNoteId.value =
isEditing ? null : note.id;
},
),
],
), ),
MySpacing.height(12), // Action buttons (always fully visible)
Row(
/// Content mainAxisAlignment: MainAxisAlignment.end,
if (isEditing && quillController != null) children: [
CommentEditorCard( if (note.isActive) ...[
controller: quillController, IconButton(
onCancel: () => icon: Icon(
controller.editingNoteId.value = null, isEditing ? Icons.close : Icons.edit,
onSave: (quillCtrl) async { color: Colors.indigo,
final delta = quillCtrl.document.toDelta(); size: 20,
final htmlOutput = _convertDeltaToHtml(delta); ),
final updated = note.copyWith(note: htmlOutput); padding: EdgeInsets.all(2),
await controller.updateNote(updated); constraints: const BoxConstraints(),
controller.editingNoteId.value = null; onPressed: () {
}, controller.editingNoteId.value =
) isEditing ? null : note.id;
else },
html.Html(
data: note.note,
style: {
"body": html.Style(
margin: html.Margins.zero,
padding: html.HtmlPaddings.zero,
fontSize: html.FontSize.medium,
color: Colors.black87,
), ),
}, IconButton(
), icon: const Icon(
Icons.delete_outline,
color: Colors.redAccent,
size: 20,
),
constraints: const BoxConstraints(),
onPressed: () async {
await Get.dialog(
ConfirmDialog(
title: "Delete Note",
message:
"Are you sure you want to delete this note?",
confirmText: "Delete",
confirmColor: Colors.redAccent,
icon: Icons.delete_forever,
onConfirm: () async {
await controller.restoreOrDeleteNote(
note,
restore: false);
},
),
barrierDismissible: false,
);
},
),
],
if (!note.isActive)
IconButton(
icon: const Icon(
Icons.restore,
color: Colors.green,
size: 22,
),
tooltip: "Restore",
onPressed: () async {
await Get.dialog(
ConfirmDialog(
title: "Restore Note",
message:
"Are you sure you want to restore this note?",
confirmText: "Restore",
confirmColor: Colors.green,
icon: Icons.restore,
onConfirm: () async {
await controller.restoreOrDeleteNote(
note,
restore: true);
},
),
barrierDismissible: false,
);
},
),
],
),
], ],
), ),
); );

View File

@ -25,9 +25,10 @@ class DocumentDetailsPage extends StatefulWidget {
class _DocumentDetailsPageState extends State<DocumentDetailsPage> { class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
final DocumentDetailsController controller = final DocumentDetailsController controller =
Get.put(DocumentDetailsController()); Get.find<DocumentDetailsController>();
final PermissionController permissionController =
Get.find<PermissionController>(); final permissionController = Get.put(PermissionController());
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -108,7 +109,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.06), color: Colors.black.withOpacity(0.06),
@ -190,7 +191,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: borderRadius:
BorderRadius.vertical(top: Radius.circular(20)), BorderRadius.vertical(top: Radius.circular(5)),
), ),
builder: (_) { builder: (_) {
return DocumentEditBottomSheet( return DocumentEditBottomSheet(
@ -246,7 +247,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.green, backgroundColor: Colors.green,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
), ),
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
), ),
@ -280,7 +281,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, backgroundColor: Colors.red,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
), ),
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
), ),
@ -377,7 +378,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
margin: const EdgeInsets.only(right: 6, bottom: 6), margin: const EdgeInsets.only(right: 6, bottom: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blue.shade100, color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
), ),
child: MyText.bodySmall( child: MyText.bodySmall(
label, label,

View File

@ -1,22 +1,24 @@
import 'dart:convert';
import 'package:flutter/material.dart'; 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/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_refresh_indicator.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/model/document/user_document_filter_bottom_sheet.dart';
import 'package:marco/model/document/documents_list_model.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/controller/document/document_details_controller.dart';
import 'package:marco/model/document/document_upload_bottom_sheet.dart';
import 'package:marco/controller/document/document_upload_controller.dart'; import 'package:marco/controller/document/document_upload_controller.dart';
import 'package:marco/view/document/document_details_page.dart'; import 'package:marco/controller/document/user_document_controller.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/custom_app_bar.dart'; import 'package:marco/helpers/widgets/custom_app_bar.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.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/widgets/my_snackbar.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/model/document/document_upload_bottom_sheet.dart';
import 'package:marco/model/document/documents_list_model.dart';
import 'package:marco/model/document/user_document_filter_bottom_sheet.dart';
import 'package:marco/view/document/document_details_page.dart';
class UserDocumentsPage extends StatefulWidget { class UserDocumentsPage extends StatefulWidget {
final String? entityId; final String? entityId;
@ -34,8 +36,8 @@ class UserDocumentsPage extends StatefulWidget {
class _UserDocumentsPageState extends State<UserDocumentsPage> { class _UserDocumentsPageState extends State<UserDocumentsPage> {
final DocumentController docController = Get.put(DocumentController()); final DocumentController docController = Get.put(DocumentController());
final PermissionController permissionController = final PermissionController permissionController = Get.put(PermissionController());
Get.find<PermissionController>(); final DocumentDetailsController controller = Get.put(DocumentDetailsController());
String get entityTypeId => widget.isEmployee String get entityTypeId => widget.isEmployee
? Permissions.employeeEntity ? Permissions.employeeEntity
@ -64,30 +66,27 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
super.dispose(); super.dispose();
} }
Widget _buildDocumentTile(DocumentItem doc) { Widget _buildDocumentTile(DocumentItem doc, bool showDateHeader) {
final uploadDate = final uploadDate = DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal());
DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal());
final uploader = doc.uploadedBy.firstName.isNotEmpty final uploader = doc.uploadedBy.firstName.isNotEmpty
? "Added by ${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}" ? "Added by ${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}".trim()
.trim()
: "Added by you"; : "Added by you";
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( if (showDateHeader)
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), Padding(
child: MyText.bodySmall( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
uploadDate, child: MyText.bodySmall(
fontSize: 13, uploadDate,
fontWeight: 500, fontSize: 13,
color: Colors.grey, fontWeight: 500,
color: Colors.grey,
),
), ),
),
InkWell( InkWell(
onTap: () { onTap: () {
// 👉 Navigate to details page
Get.to(() => DocumentDetailsPage(documentId: doc.id)); Get.to(() => DocumentDetailsPage(documentId: doc.id));
}, },
child: Container( child: Container(
@ -95,7 +94,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.05), color: Colors.black.withOpacity(0.05),
@ -111,7 +110,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blue.shade50, color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(5),
), ),
child: const Icon(Icons.description, color: Colors.blue), child: const Icon(Icons.description, color: Colors.blue),
), ),
@ -142,92 +141,90 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
], ],
), ),
), ),
PopupMenuButton<String>( Obx(() {
icon: const Icon(Icons.more_vert, color: Colors.black54), // React to permission changes
onSelected: (value) async { return PopupMenuButton<String>(
if (value == "delete") { icon: const Icon(Icons.more_vert, color: Colors.black54),
// existing delete flow (unchanged) onSelected: (value) async {
final result = await showDialog<bool>( if (value == "delete") {
context: context, final result = await showDialog<bool>(
builder: (_) => ConfirmDialog( context: context,
title: "Delete Document", builder: (_) => ConfirmDialog(
message: title: "Delete Document",
"Are you sure you want to delete \"${doc.name}\"?\nThis action cannot be undone.", message:
confirmText: "Delete", "Are you sure you want to delete \"${doc.name}\"?\nThis action cannot be undone.",
cancelText: "Cancel", confirmText: "Delete",
icon: Icons.delete_forever, cancelText: "Cancel",
confirmColor: Colors.redAccent, icon: Icons.delete_forever,
onConfirm: () async { confirmColor: Colors.redAccent,
final success = onConfirm: () async {
await docController.toggleDocumentActive( final success =
doc.id, await docController.toggleDocumentActive(
isActive: false, doc.id,
entityTypeId: entityTypeId, isActive: false,
entityId: resolvedEntityId, entityTypeId: entityTypeId,
); entityId: resolvedEntityId,
);
if (success) { if (success) {
showAppSnackbar( showAppSnackbar(
title: "Deleted", title: "Deleted",
message: "Document deleted successfully", message: "Document deleted successfully",
type: SnackbarType.success, type: SnackbarType.success,
); );
} else { } else {
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Failed to delete document", message: "Failed to delete document",
type: SnackbarType.error, type: SnackbarType.error,
); );
throw Exception( throw Exception("Failed to delete");
"Failed to delete"); // keep dialog open }
} },
}, ),
);
if (result == true) {
debugPrint("✅ Document deleted and removed from list");
}
} else if (value == "restore") {
final success = await docController.toggleDocumentActive(
doc.id,
isActive: true,
entityTypeId: entityTypeId,
entityId: resolvedEntityId,
);
if (success) {
showAppSnackbar(
title: "Restored",
message: "Document restored successfully",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to restore document",
type: SnackbarType.error,
);
}
}
},
itemBuilder: (context) => [
if (doc.isActive &&
permissionController.hasPermission(Permissions.deleteDocument))
const PopupMenuItem(
value: "delete",
child: Text("Delete"),
)
else if (!doc.isActive &&
permissionController.hasPermission(Permissions.modifyDocument))
const PopupMenuItem(
value: "restore",
child: Text("Restore"),
), ),
); ],
if (result == true) { );
debugPrint("✅ Document deleted and removed from list"); }),
}
} else if (value == "activate") {
// existing activate flow (unchanged)
final success = await docController.toggleDocumentActive(
doc.id,
isActive: true,
entityTypeId: entityTypeId,
entityId: resolvedEntityId,
);
if (success) {
showAppSnackbar(
title: "Reactivated",
message: "Document reactivated successfully",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to reactivate document",
type: SnackbarType.error,
);
}
}
},
itemBuilder: (context) => [
if (doc.isActive &&
permissionController
.hasPermission(Permissions.deleteDocument))
const PopupMenuItem(
value: "delete",
child: Text("Delete"),
)
else if (!doc.isActive &&
permissionController
.hasPermission(Permissions.modifyDocument))
const PopupMenuItem(
value: "activate",
child: Text("Activate"),
),
],
),
], ],
), ),
), ),
@ -263,7 +260,6 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
padding: MySpacing.xy(8, 8), padding: MySpacing.xy(8, 8),
child: Row( child: Row(
children: [ children: [
// 🔍 Search Bar
Expanded( Expanded(
child: SizedBox( child: SizedBox(
height: 35, height: 35,
@ -279,15 +275,13 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
}, },
decoration: InputDecoration( decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 12),
prefixIcon: prefixIcon: const Icon(Icons.search, size: 20, color: Colors.grey),
const Icon(Icons.search, size: 20, color: Colors.grey),
suffixIcon: ValueListenableBuilder<TextEditingValue>( suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: docController.searchController, valueListenable: docController.searchController,
builder: (context, value, _) { builder: (context, value, _) {
if (value.text.isEmpty) return const SizedBox.shrink(); if (value.text.isEmpty) return const SizedBox.shrink();
return IconButton( return IconButton(
icon: const Icon(Icons.clear, icon: const Icon(Icons.clear, size: 20, color: Colors.grey),
size: 20, color: Colors.grey),
onPressed: () { onPressed: () {
docController.searchController.clear(); docController.searchController.clear();
docController.searchQuery.value = ''; docController.searchQuery.value = '';
@ -304,11 +298,11 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
filled: true, filled: true,
fillColor: Colors.white, fillColor: Colors.white,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
), ),
@ -316,8 +310,6 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
), ),
), ),
MySpacing.width(8), MySpacing.width(8),
// 🛠 Filter Icon with indicator
Obx(() { Obx(() {
final isFilterActive = docController.hasActiveFilters(); final isFilterActive = docController.hasActiveFilters();
return Stack( return Stack(
@ -328,23 +320,18 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
), ),
child: IconButton( child: IconButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: BoxConstraints(), constraints: BoxConstraints(),
icon: Icon( icon: Icon(Icons.tune, size: 20, color: Colors.black87),
Icons.tune,
size: 20,
color: Colors.black87,
),
onPressed: () { onPressed: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: borderRadius: BorderRadius.vertical(top: Radius.circular(5)),
BorderRadius.vertical(top: Radius.circular(20)),
), ),
builder: (_) => UserDocumentFilterBottomSheet( builder: (_) => UserDocumentFilterBottomSheet(
entityId: resolvedEntityId, entityId: resolvedEntityId,
@ -371,22 +358,19 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
); );
}), }),
MySpacing.width(10), MySpacing.width(10),
// Menu (Show Inactive toggle)
Container( Container(
height: 35, height: 35,
width: 35, width: 35,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
), ),
child: PopupMenuButton<int>( child: PopupMenuButton<int>(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
icon: icon: const Icon(Icons.more_vert, size: 20, color: Colors.black87),
const Icon(Icons.more_vert, size: 20, color: Colors.black87),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
), ),
itemBuilder: (context) => [ itemBuilder: (context) => [
const PopupMenuItem<int>( const PopupMenuItem<int>(
@ -408,7 +392,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
const Icon(Icons.visibility_off_outlined, const Icon(Icons.visibility_off_outlined,
size: 20, color: Colors.black87), size: 20, color: Colors.black87),
const SizedBox(width: 10), const SizedBox(width: 10),
const Expanded(child: Text('Show Inactive')), const Expanded(child: Text('Show Deleted Documents')),
Switch.adaptive( Switch.adaptive(
value: docController.showInactive.value, value: docController.showInactive.value,
activeColor: Colors.indigo, activeColor: Colors.indigo,
@ -435,27 +419,19 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
Widget _buildStatusHeader() { Widget _buildStatusHeader() {
return Obx(() { return Obx(() {
final isInactive = docController.showInactive.value; if (!docController.showInactive.value) return const SizedBox.shrink();
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: isInactive ? Colors.red.shade50 : Colors.green.shade50, color: Colors.red.shade50,
child: Row( child: Row(
children: [ children: [
Icon( Icon(Icons.visibility_off, color: Colors.red, size: 18),
isInactive ? Icons.visibility_off : Icons.check_circle,
color: isInactive ? Colors.red : Colors.green,
size: 18,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
isInactive "Showing Deleted Documents",
? "Showing Inactive Documents" style: TextStyle(color: Colors.red, fontWeight: FontWeight.w600),
: "Showing Active Documents",
style: TextStyle(
color: isInactive ? Colors.red : Colors.green,
fontWeight: FontWeight.w600,
),
), ),
], ],
), ),
@ -464,30 +440,33 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
} }
Widget _buildBody(BuildContext context) { Widget _buildBody(BuildContext context) {
// 🔒 Check for viewDocument permission
if (!permissionController.hasPermission(Permissions.viewDocument)) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.lock_outline, size: 60, color: Colors.grey),
MySpacing.height(18),
MyText.titleMedium(
'Access Denied',
fontWeight: 600,
color: Colors.grey,
),
MySpacing.height(10),
MyText.bodySmall(
'You do not have permission to view documents.',
color: Colors.grey,
),
],
),
);
}
return Obx(() { return Obx(() {
if (permissionController.permissions.isEmpty) {
return Center(child: CircularProgressIndicator());
}
if (!permissionController.hasPermission(Permissions.viewDocument)) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.lock_outline, size: 60, color: Colors.grey),
MySpacing.height(18),
MyText.titleMedium(
'Access Denied',
fontWeight: 600,
color: Colors.grey,
),
MySpacing.height(10),
MyText.bodySmall(
'You do not have permission to view documents.',
color: Colors.grey,
),
],
),
);
}
if (docController.isLoading.value && docController.documents.isEmpty) { if (docController.isLoading.value && docController.documents.isEmpty) {
return SingleChildScrollView( return SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
@ -504,18 +483,23 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
Expanded( Expanded(
child: MyRefreshIndicator( child: MyRefreshIndicator(
onRefresh: () async { onRefresh: () async {
final combinedFilter = {
'uploadedByIds': docController.selectedUploadedBy.toList(),
'documentCategoryIds': docController.selectedCategory.toList(),
'documentTypeIds': docController.selectedType.toList(),
'documentTagIds': docController.selectedTag.toList(),
};
await docController.fetchDocuments( await docController.fetchDocuments(
entityTypeId: entityTypeId, entityTypeId: entityTypeId,
entityId: resolvedEntityId, entityId: resolvedEntityId,
filter: docController.selectedFilter.value, filter: jsonEncode(combinedFilter),
reset: true, reset: true,
); );
}, },
child: ListView( child: ListView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: docs.isEmpty padding: docs.isEmpty ? null : const EdgeInsets.fromLTRB(0, 0, 0, 80),
? null
: const EdgeInsets.fromLTRB(0, 0, 0, 80),
children: docs.isEmpty children: docs.isEmpty
? [ ? [
SizedBox( SizedBox(
@ -524,7 +508,21 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
), ),
] ]
: [ : [
...docs.map(_buildDocumentTile), ...docs.asMap().entries.map((entry) {
final index = entry.key;
final doc = entry.value;
final currentDate = DateFormat("dd MMM yyyy")
.format(doc.uploadedAt.toLocal());
final prevDate = index > 0
? DateFormat("dd MMM yyyy")
.format(docs[index - 1].uploadedAt.toLocal())
: null;
final showDateHeader = currentDate != prevDate;
return _buildDocumentTile(doc, showDateHeader);
}),
if (docController.isLoading.value) if (docController.isLoading.value)
const Padding( const Padding(
padding: EdgeInsets.all(12), padding: EdgeInsets.all(12),
@ -565,56 +563,61 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
) )
: null, : null,
body: _buildBody(context), body: _buildBody(context),
floatingActionButton: permissionController floatingActionButton: Obx(() {
.hasPermission(Permissions.uploadDocument) if (permissionController.permissions.isEmpty) return SizedBox.shrink();
? FloatingActionButton.extended(
onPressed: () {
final uploadController = Get.put(DocumentUploadController());
showModalBottomSheet( return permissionController.hasPermission(Permissions.uploadDocument)
context: context, ? FloatingActionButton.extended(
isScrollControlled: true, onPressed: () {
backgroundColor: Colors.transparent, final uploadController = Get.put(DocumentUploadController());
builder: (_) => DocumentUploadBottomSheet(
isEmployee:
widget.isEmployee, // 👈 Pass the employee flag here
onSubmit: (data) async {
final success = await uploadController.uploadDocument(
name: data["name"],
description: data["description"],
documentId: data["documentId"],
entityId: resolvedEntityId,
documentTypeId: data["documentTypeId"],
fileName: data["attachment"]["fileName"],
base64Data: data["attachment"]["base64Data"],
contentType: data["attachment"]["contentType"],
fileSize: data["attachment"]["fileSize"],
);
if (success) { showModalBottomSheet(
Navigator.pop(context); context: context,
docController.fetchDocuments( isScrollControlled: true,
entityTypeId: entityTypeId, backgroundColor: Colors.transparent,
builder: (_) => DocumentUploadBottomSheet(
isEmployee: widget.isEmployee,
onSubmit: (data) async {
final success = await uploadController.uploadDocument(
name: data["name"],
description: data["description"],
documentId: data["documentId"],
entityId: resolvedEntityId, entityId: resolvedEntityId,
reset: true, documentTypeId: data["documentTypeId"],
fileName: data["attachment"]["fileName"],
base64Data: data["attachment"]["base64Data"],
contentType: data["attachment"]["contentType"],
fileSize: data["attachment"]["fileSize"],
); );
} else {
Get.snackbar( if (success) {
"Error", "Upload failed, please try again"); Navigator.pop(context);
} docController.fetchDocuments(
}, entityTypeId: entityTypeId,
), entityId: resolvedEntityId,
); reset: true,
}, );
icon: const Icon(Icons.add, color: Colors.white), } else {
label: MyText.bodyMedium( showAppSnackbar(
"Add Document", title: "Error",
color: Colors.white, message: "Upload failed, please try again",
fontWeight: 600, type: SnackbarType.error,
), );
backgroundColor: Colors.red, }
) },
: null, ),
);
},
icon: const Icon(Icons.add, color: Colors.white),
label: MyText.bodyMedium(
"Add Document",
color: Colors.white,
fontWeight: 600,
),
backgroundColor: Colors.red,
)
: SizedBox.shrink();
}),
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
); );
} }

View File

@ -11,6 +11,7 @@ import 'package:marco/helpers/utils/launcher_utils.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/model/employees/add_employee_bottom_sheet.dart';
class EmployeeDetailPage extends StatefulWidget { class EmployeeDetailPage extends StatefulWidget {
final String employeeId; final String employeeId;
@ -29,8 +30,8 @@ class EmployeeDetailPage extends StatefulWidget {
class _EmployeeDetailPageState extends State<EmployeeDetailPage> { class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
final EmployeesScreenController controller = final EmployeesScreenController controller =
Get.put(EmployeesScreenController()); Get.put(EmployeesScreenController());
final PermissionController _permissionController = final PermissionController permissionController =
Get.find<PermissionController>(); Get.put(PermissionController());
@override @override
void initState() { void initState() {
@ -92,8 +93,9 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
fontWeight: FontWeight.normal, fontWeight: FontWeight.normal,
color: (isEmail || isPhone) ? Colors.indigo : Colors.black54, color: (isEmail || isPhone) ? Colors.indigo : Colors.black54,
fontSize: 14, fontSize: 14,
decoration: decoration: (isEmail || isPhone)
(isEmail || isPhone) ? TextDecoration.underline : TextDecoration.none, ? TextDecoration.underline
: TextDecoration.none,
), ),
), ),
); );
@ -151,7 +153,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
return Card( return Card(
elevation: 3, elevation: 3,
shadowColor: Colors.black12, shadowColor: Colors.black12,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(12, 16, 12, 16), padding: const EdgeInsets.fromLTRB(12, 16, 12, 16),
child: Column( child: Column(
@ -231,7 +233,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
lastName: employee.lastName, lastName: employee.lastName,
size: 45, size: 45,
), ),
MySpacing.width(16), MySpacing.width(12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -248,6 +250,38 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
], ],
), ),
), ),
IconButton(
icon:
const Icon(Icons.edit, size: 24, color: Colors.red),
onPressed: () async {
final result =
await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => AddEmployeeBottomSheet(
employeeData: {
'id': employee.id,
'first_name': employee.firstName,
'last_name': employee.lastName,
'phone_number': employee.phoneNumber,
'email': employee.email,
'hasApplicationAccess':
employee.hasApplicationAccess,
'gender': employee.gender.toLowerCase(),
'job_role_id': employee.jobRoleId,
'joining_date':
employee.joiningDate?.toIso8601String(),
'organization_id': employee.organizationId,
},
),
);
if (result != null) {
controller.fetchEmployeeDetails(widget.employeeId);
}
},
),
], ],
), ),
MySpacing.height(14), MySpacing.height(14),
@ -259,7 +293,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
); );
}), }),
floatingActionButton: Obx(() { floatingActionButton: Obx(() {
if (!_permissionController.hasPermission(Permissions.assignToProject)) { if (!permissionController.hasPermission(Permissions.assignToProject)) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
if (controller.isLoadingEmployeeDetails.value || if (controller.isLoadingEmployeeDetails.value ||

View File

@ -16,6 +16,8 @@ import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/view/employees/employee_profile_screen.dart'; import 'package:marco/view/employees/employee_profile_screen.dart';
import 'package:marco/controller/tenant/organization_selection_controller.dart';
import 'package:marco/helpers/widgets/tenant/organization_selector.dart';
class EmployeesScreen extends StatefulWidget { class EmployeesScreen extends StatefulWidget {
const EmployeesScreen({super.key}); const EmployeesScreen({super.key});
@ -27,10 +29,12 @@ class EmployeesScreen extends StatefulWidget {
class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin { class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
final EmployeesScreenController _employeeController = final EmployeesScreenController _employeeController =
Get.put(EmployeesScreenController()); Get.put(EmployeesScreenController());
final PermissionController _permissionController = final PermissionController permissionController =
Get.find<PermissionController>(); Get.put(PermissionController());
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs; final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs;
final OrganizationController _organizationController =
Get.put(OrganizationController());
@override @override
void initState() { void initState() {
@ -44,13 +48,19 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
Future<void> _initEmployees() async { Future<void> _initEmployees() async {
final projectId = Get.find<ProjectController>().selectedProject?.id; final projectId = Get.find<ProjectController>().selectedProject?.id;
final orgId = _organizationController.selectedOrganization.value?.id;
if (projectId != null) {
await _organizationController.fetchOrganizations(projectId);
}
if (_employeeController.isAllEmployeeSelected.value) { if (_employeeController.isAllEmployeeSelected.value) {
_employeeController.selectedProjectId = null; _employeeController.selectedProjectId = null;
await _employeeController.fetchAllEmployees(); await _employeeController.fetchAllEmployees(organizationId: orgId);
} else if (projectId != null) { } else if (projectId != null) {
_employeeController.selectedProjectId = projectId; _employeeController.selectedProjectId = projectId;
await _employeeController.fetchEmployeesByProject(projectId); await _employeeController.fetchEmployeesByProject(projectId,
organizationId: orgId);
} else { } else {
_employeeController.clearEmployees(); _employeeController.clearEmployees();
} }
@ -61,14 +71,16 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
Future<void> _refreshEmployees() async { Future<void> _refreshEmployees() async {
try { try {
final projectId = Get.find<ProjectController>().selectedProject?.id; final projectId = Get.find<ProjectController>().selectedProject?.id;
final orgId = _organizationController.selectedOrganization.value?.id;
final allSelected = _employeeController.isAllEmployeeSelected.value; final allSelected = _employeeController.isAllEmployeeSelected.value;
_employeeController.selectedProjectId = allSelected ? null : projectId; _employeeController.selectedProjectId = allSelected ? null : projectId;
if (allSelected) { if (allSelected) {
await _employeeController.fetchAllEmployees(); await _employeeController.fetchAllEmployees(organizationId: orgId);
} else if (projectId != null) { } else if (projectId != null) {
await _employeeController.fetchEmployeesByProject(projectId); await _employeeController.fetchEmployeesByProject(projectId,
organizationId: orgId);
} else { } else {
_employeeController.clearEmployees(); _employeeController.clearEmployees();
} }
@ -236,43 +248,95 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
} }
Widget _buildFloatingActionButton() { Widget _buildFloatingActionButton() {
if (!_permissionController.hasPermission(Permissions.manageEmployees)) { return Obx(() {
return const SizedBox.shrink(); // Show nothing while permissions are loading
} if (permissionController.isLoading.value) {
return const SizedBox.shrink();
}
return InkWell( // Show FAB only if user has Manage Employees permission
onTap: _onAddNewEmployee, final hasPermission =
borderRadius: BorderRadius.circular(28), permissionController.hasPermission(Permissions.manageEmployees);
child: Container( if (!hasPermission) {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), return const SizedBox.shrink();
decoration: BoxDecoration( }
color: Colors.red,
borderRadius: BorderRadius.circular(28), return InkWell(
boxShadow: const [ onTap: _onAddNewEmployee,
BoxShadow( borderRadius: BorderRadius.circular(28),
color: Colors.black26, blurRadius: 6, offset: Offset(0, 3)) child: Container(
], padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(28),
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 6,
offset: Offset(0, 3),
)
],
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, color: Colors.white),
SizedBox(width: 8),
Text('Add New Employee', style: TextStyle(color: Colors.white)),
],
),
), ),
child: const Row( );
mainAxisSize: MainAxisSize.min, });
children: [
Icon(Icons.add, color: Colors.white),
SizedBox(width: 8),
Text('Add New Employee', style: TextStyle(color: Colors.white)),
],
),
),
);
} }
Widget _buildSearchAndActionRow() { Widget _buildSearchAndActionRow() {
return Padding( return Padding(
padding: MySpacing.x(flexSpacing), padding: MySpacing.x(15),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded(child: _buildSearchField()), // Search Field Row
const SizedBox(width: 8), Row(
_buildPopupMenu(), children: [
Expanded(child: _buildSearchField()),
const SizedBox(width: 8),
_buildPopupMenu(),
],
),
// Organization Selector Row
Row(
children: [
Expanded(
child: OrganizationSelector(
controller: _organizationController,
height: 36,
onSelectionChanged: (org) async {
// Make sure the selectedOrganization is updated immediately
_organizationController.selectOrganization(org);
final projectId =
Get.find<ProjectController>().selectedProject?.id;
if (_employeeController.isAllEmployeeSelected.value) {
await _employeeController.fetchAllEmployees(
organizationId: _organizationController
.selectedOrganization.value?.id);
} else if (projectId != null) {
await _employeeController.fetchEmployeesByProject(
projectId,
organizationId: _organizationController
.selectedOrganization.value?.id);
}
_employeeController.update(['employee_screen_controller']);
},
),
),
],
),
MySpacing.height(8),
], ],
), ),
); );
@ -320,60 +384,63 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
} }
Widget _buildPopupMenu() { Widget _buildPopupMenu() {
if (!_permissionController.hasPermission(Permissions.viewAllEmployees)) { return Obx(() {
return const SizedBox.shrink(); if (permissionController.isLoading.value ||
} !permissionController.hasPermission(Permissions.viewAllEmployees)) {
return const SizedBox.shrink();
}
return PopupMenuButton<String>( return PopupMenuButton<String>(
icon: Stack( icon: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
const Icon(Icons.tune, color: Colors.black), const Icon(Icons.tune, color: Colors.black),
Obx(() => _employeeController.isAllEmployeeSelected.value Obx(() => _employeeController.isAllEmployeeSelected.value
? Positioned( ? Positioned(
right: -1, right: -1,
top: -1, top: -1,
child: Container( child: Container(
width: 10, width: 10,
height: 10, height: 10,
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Colors.red, shape: BoxShape.circle), color: Colors.red, shape: BoxShape.circle),
),
)
: const SizedBox.shrink()),
],
),
onSelected: (value) async {
if (value == 'all_employees') {
_employeeController.isAllEmployeeSelected.toggle();
await _initEmployees();
_employeeController.update(['employee_screen_controller']);
}
},
itemBuilder: (_) => [
PopupMenuItem<String>(
value: 'all_employees',
child: Obx(
() => Row(
children: [
Checkbox(
value: _employeeController.isAllEmployeeSelected.value,
onChanged: (_) => Navigator.pop(context, 'all_employees'),
checkColor: Colors.white,
activeColor: Colors.blueAccent,
side: const BorderSide(color: Colors.black, width: 1.5),
fillColor: MaterialStateProperty.resolveWith<Color>(
(states) => states.contains(MaterialState.selected)
? Colors.blueAccent
: Colors.white),
), ),
) const Text('All Employees'),
: const SizedBox.shrink()), ],
], ),
),
onSelected: (value) async {
if (value == 'all_employees') {
_employeeController.isAllEmployeeSelected.toggle();
await _initEmployees();
_employeeController.update(['employee_screen_controller']);
}
},
itemBuilder: (_) => [
PopupMenuItem<String>(
value: 'all_employees',
child: Obx(
() => Row(
children: [
Checkbox(
value: _employeeController.isAllEmployeeSelected.value,
onChanged: (_) => Navigator.pop(context, 'all_employees'),
checkColor: Colors.white,
activeColor: Colors.blueAccent,
side: const BorderSide(color: Colors.black, width: 1.5),
fillColor: MaterialStateProperty.resolveWith<Color>(
(states) => states.contains(MaterialState.selected)
? Colors.blueAccent
: Colors.white),
),
const Text('All Employees'),
],
), ),
), ),
), ],
], );
); });
} }
Widget _buildEmployeeList() { Widget _buildEmployeeList() {

View File

@ -21,6 +21,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/model/employees/employee_info.dart'; import 'package:marco/model/employees/employee_info.dart';
import 'package:timeline_tile/timeline_tile.dart'; import 'package:timeline_tile/timeline_tile.dart';
class ExpenseDetailScreen extends StatefulWidget { class ExpenseDetailScreen extends StatefulWidget {
final String expenseId; final String expenseId;
const ExpenseDetailScreen({super.key, required this.expenseId}); const ExpenseDetailScreen({super.key, required this.expenseId});
@ -32,7 +33,7 @@ class ExpenseDetailScreen extends StatefulWidget {
class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> { class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
final controller = Get.put(ExpenseDetailController()); final controller = Get.put(ExpenseDetailController());
final projectController = Get.find<ProjectController>(); final projectController = Get.find<ProjectController>();
final permissionController = Get.find<PermissionController>(); final permissionController = Get.put(PermissionController());
EmployeeInfo? employeeInfo; EmployeeInfo? employeeInfo;
final RxBool canSubmit = false.obs; final RxBool canSubmit = false.obs;
@ -105,7 +106,7 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
constraints: const BoxConstraints(maxWidth: 520), constraints: const BoxConstraints(maxWidth: 520),
child: Card( child: Card(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)), borderRadius: BorderRadius.circular(5)),
elevation: 3, elevation: 3,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -115,20 +116,20 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
children: [ children: [
_InvoiceHeader(expense: expense), _InvoiceHeader(expense: expense),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
InvoiceLogs(logs: expense.expenseLogs),
const Divider(height: 30, thickness: 1.2),
_InvoiceParties(expense: expense), _InvoiceParties(expense: expense),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
_InvoiceDetailsTable(expense: expense), _InvoiceDetailsTable(expense: expense),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
_InvoiceDocuments(documents: expense.documents), _InvoiceDocuments(documents: expense.documents),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
_InvoiceTotals( _InvoiceTotals(
expense: expense, expense: expense,
formattedAmount: formattedAmount, formattedAmount: formattedAmount,
statusColor: statusColor, statusColor: statusColor,
), ),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
InvoiceLogs(logs: expense.expenseLogs),
], ],
), ),
), ),
@ -158,7 +159,7 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return FloatingActionButton( return FloatingActionButton.extended(
onPressed: () async { onPressed: () async {
final editData = { final editData = {
'id': expense.id, 'id': expense.id,
@ -195,8 +196,9 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
await controller.fetchExpenseDetails(); await controller.fetchExpenseDetails();
}, },
backgroundColor: Colors.red, backgroundColor: Colors.red,
tooltip: 'Edit Expense', icon: const Icon(Icons.edit),
child: const Icon(Icons.edit), label: MyText.bodyMedium(
"Edit Expense", fontWeight: 600, color: Colors.white),
); );
}), }),
bottomNavigationBar: Obx(() { bottomNavigationBar: Obx(() {
@ -269,7 +271,7 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
minimumSize: const Size(100, 40), minimumSize: const Size(100, 40),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
backgroundColor: buttonColor, backgroundColor: buttonColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
), ),
onPressed: () async { onPressed: () async {
const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27'; const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27';
@ -278,7 +280,7 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))), borderRadius: BorderRadius.vertical(top: Radius.circular(5))),
builder: (context) => ReimbursementBottomSheet( builder: (context) => ReimbursementBottomSheet(
expenseId: expense.id, expenseId: expense.id,
statusId: next.id, statusId: next.id,
@ -468,7 +470,7 @@ class _InvoiceHeader extends StatelessWidget {
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: statusColor.withOpacity(0.15), color: statusColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(8)), borderRadius: BorderRadius.circular(5)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
child: Row( child: Row(
children: [ children: [
@ -602,7 +604,7 @@ class _InvoiceDocuments extends StatelessWidget {
const EdgeInsets.symmetric(horizontal: 12, vertical: 8), const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
color: Colors.grey.shade100, color: Colors.grey.shade100,
), ),
child: Row( child: Row(
@ -677,7 +679,8 @@ class InvoiceLogs extends StatelessWidget {
), ),
), ),
), ),
beforeLineStyle: LineStyle(color: Colors.grey.shade300, thickness: 2), beforeLineStyle:
LineStyle(color: Colors.grey.shade300, thickness: 2),
endChild: Padding( endChild: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
child: Column( child: Column(
@ -696,17 +699,20 @@ class InvoiceLogs extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
children: [ children: [
Icon(Icons.access_time, size: 14, color: Colors.grey[600]), Icon(Icons.access_time,
size: 14, color: Colors.grey[600]),
const SizedBox(width: 4), const SizedBox(width: 4),
MyText.bodySmall(formattedDate, color: Colors.grey[700]), MyText.bodySmall(formattedDate,
color: Colors.grey[700]),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blue.shade50, color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
child: MyText.bodySmall( child: MyText.bodySmall(
log.action, log.action,

View File

@ -408,4 +408,5 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
], ],
); );
} }
} }

View File

@ -20,19 +20,27 @@ class ExpenseMainScreen extends StatefulWidget {
State<ExpenseMainScreen> createState() => _ExpenseMainScreenState(); State<ExpenseMainScreen> createState() => _ExpenseMainScreenState();
} }
class _ExpenseMainScreenState extends State<ExpenseMainScreen> { class _ExpenseMainScreenState extends State<ExpenseMainScreen>
bool isHistoryView = false; with SingleTickerProviderStateMixin {
late TabController _tabController;
final searchController = TextEditingController(); final searchController = TextEditingController();
final expenseController = Get.put(ExpenseController()); final expenseController = Get.put(ExpenseController());
final projectController = Get.find<ProjectController>(); final projectController = Get.find<ProjectController>();
final permissionController = Get.find<PermissionController>(); final permissionController = Get.put(PermissionController());
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 2, vsync: this);
expenseController.fetchExpenses(); expenseController.fetchExpenses();
} }
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _refreshExpenses() async { Future<void> _refreshExpenses() async {
await expenseController.fetchExpenses(); await expenseController.fetchExpenses();
} }
@ -49,7 +57,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
); );
} }
List<ExpenseModel> _getFilteredExpenses() { List<ExpenseModel> _getFilteredExpenses({required bool isHistory}) {
final query = searchController.text.trim().toLowerCase(); final query = searchController.text.trim().toLowerCase();
final now = DateTime.now(); final now = DateTime.now();
@ -61,7 +69,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
}).toList() }).toList()
..sort((a, b) => b.transactionDate.compareTo(a.transactionDate)); ..sort((a, b) => b.transactionDate.compareTo(a.transactionDate));
return isHistoryView return isHistory
? filtered ? filtered
.where((e) => .where((e) =>
e.transactionDate.isBefore(DateTime(now.year, now.month))) e.transactionDate.isBefore(DateTime(now.year, now.month)))
@ -72,89 +80,131 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
e.transactionDate.year == now.year) e.transactionDate.year == now.year)
.toList(); .toList();
} }
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: ExpenseAppBar(projectController: projectController),
body: SafeArea(
child: Column(
children: [
SearchAndFilter(
controller: searchController,
onChanged: (_) => setState(() {}),
onFilterTap: _openFilterBottomSheet,
expenseController: expenseController,
),
ToggleButtonsRow(
isHistoryView: isHistoryView,
onToggle: (v) => setState(() => isHistoryView = v),
),
Expanded(
child: Obx(() {
// Loader while fetching first time
if (expenseController.isLoading.value &&
expenseController.expenses.isEmpty) {
return SkeletonLoaders.expenseListSkeletonLoader();
}
final filteredList = _getFilteredExpenses(); @override
Widget build(BuildContext context) {
return MyRefreshIndicator( return Scaffold(
onRefresh: _refreshExpenses, backgroundColor: Colors.white,
child: filteredList.isEmpty appBar: ExpenseAppBar(projectController: projectController),
? ListView( body: Column(
physics: children: [
const AlwaysScrollableScrollPhysics(), // ---------------- TabBar ----------------
children: [ Container(
SizedBox( color: Colors.white,
height: MediaQuery.of(context).size.height * 0.5, child: TabBar(
child: Center( controller: _tabController,
child: MyText.bodyMedium( labelColor: Colors.black,
expenseController.errorMessage.isNotEmpty unselectedLabelColor: Colors.grey,
? expenseController.errorMessage.value indicatorColor: Colors.red,
: "No expenses found", tabs: const [
color: Tab(text: "Current Month"),
expenseController.errorMessage.isNotEmpty Tab(text: "History"),
? Colors.red ],
: Colors.grey, ),
),
),
),
],
)
: NotificationListener<ScrollNotification>(
onNotification: (scrollInfo) {
if (scrollInfo.metrics.pixels ==
scrollInfo.metrics.maxScrollExtent &&
!expenseController.isLoading.value) {
expenseController.loadMoreExpenses();
}
return false;
},
child: ExpenseList(
expenseList: filteredList,
onViewDetail: () =>
expenseController.fetchExpenses(),
),
),
);
}),
)
],
), ),
),
// FAB only if user has expenseUpload permission // ---------------- Gray background for rest ----------------
floatingActionButton: Expanded(
permissionController.hasPermission(Permissions.expenseUpload) child: Container(
? FloatingActionButton( color: Colors.grey[100],
backgroundColor: Colors.red, child: Column(
onPressed: showAddExpenseBottomSheet, children: [
child: const Icon(Icons.add, color: Colors.white), // ---------------- Search ----------------
) Padding(
: null, padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
); child: SearchAndFilter(
controller: searchController,
onChanged: (_) => setState(() {}),
onFilterTap: _openFilterBottomSheet,
expenseController: expenseController,
),
),
// ---------------- TabBarView ----------------
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildExpenseList(isHistory: false),
_buildExpenseList(isHistory: true),
],
),
),
],
),
),
),
],
),
// FAB reacts only to upload permission
floatingActionButton: Obx(() {
// Show loader or hide FAB while permissions are loading
if (permissionController.permissions.isEmpty) {
return const SizedBox.shrink();
}
final canUpload =
permissionController.hasPermission(Permissions.expenseUpload);
return canUpload
? FloatingActionButton(
backgroundColor: Colors.red,
onPressed: showAddExpenseBottomSheet,
child: const Icon(Icons.add, color: Colors.white),
)
: const SizedBox.shrink();
}),
);
}
Widget _buildExpenseList({required bool isHistory}) {
return Obx(() {
if (expenseController.isLoading.value &&
expenseController.expenses.isEmpty) {
return SkeletonLoaders.expenseListSkeletonLoader();
}
final filteredList = _getFilteredExpenses(isHistory: isHistory);
return MyRefreshIndicator(
onRefresh: _refreshExpenses,
child: filteredList.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: Center(
child: MyText.bodyMedium(
expenseController.errorMessage.isNotEmpty
? expenseController.errorMessage.value
: "No expenses found",
color: expenseController.errorMessage.isNotEmpty
? Colors.red
: Colors.grey,
),
),
),
],
)
: NotificationListener<ScrollNotification>(
onNotification: (scrollInfo) {
if (scrollInfo.metrics.pixels ==
scrollInfo.metrics.maxScrollExtent &&
!expenseController.isLoading.value) {
expenseController.loadMoreExpenses();
}
return false;
},
child: ExpenseList(
expenseList: filteredList,
onViewDetail: () => expenseController.fetchExpenses(),
),
),
);
});
} }
} }

View File

@ -122,7 +122,7 @@ class _LayoutState extends State<Layout> {
return Card( return Card(
elevation: 4, elevation: 4,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
), ),
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@ -133,12 +133,48 @@ class _LayoutState extends State<Layout> {
child: Row( child: Row(
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(5),
child: Image.asset( child: Stack(
Images.logoDark, clipBehavior: Clip.none,
height: 50, children: [
width: 50, Image.asset(
fit: BoxFit.contain, Images.logoDark,
height: 50,
width: 50,
fit: BoxFit.contain,
),
if (isBetaEnvironment)
Positioned(
bottom: 0,
left: 0,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.deepPurple,
borderRadius:
BorderRadius.circular(6), // capsule shape
border: Border.all(
color: Colors.white, width: 1.2),
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 2,
offset: Offset(0, 1),
)
],
),
child: const Text(
'B',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
],
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@ -218,21 +254,6 @@ class _LayoutState extends State<Layout> {
], ],
), ),
), ),
if (isBetaEnvironment)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.deepPurple,
borderRadius: BorderRadius.circular(6),
),
child: MyText.bodySmall(
'BETA',
color: Colors.white,
fontWeight: 700,
),
),
Stack( Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
alignment: Alignment.center, alignment: Alignment.center,
@ -268,7 +289,7 @@ class _LayoutState extends State<Layout> {
left: 0, left: 0,
right: 0, right: 0,
child: Container( child: Container(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(5),
color: Colors.white, color: Colors.white,
child: _buildProjectList(context, isMobile), child: _buildProjectList(context, isMobile),
), ),
@ -285,7 +306,7 @@ class _LayoutState extends State<Layout> {
return Card( return Card(
elevation: 4, elevation: 4,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
), ),
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: Padding( child: Padding(
@ -297,7 +318,7 @@ class _LayoutState extends State<Layout> {
width: 50, width: 50,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(5),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@ -343,11 +364,11 @@ class _LayoutState extends State<Layout> {
right: 16, right: 16,
child: Material( child: Material(
elevation: 4, elevation: 4,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
), ),
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
child: _buildProjectList(context, isMobile), child: _buildProjectList(context, isMobile),
@ -397,7 +418,7 @@ class _LayoutState extends State<Layout> {
? Colors.blueAccent.withOpacity(0.1) ? Colors.blueAccent.withOpacity(0.1)
: Colors.transparent, : Colors.transparent,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
visualDensity: const VisualDensity(vertical: -4), visualDensity: const VisualDensity(vertical: -4),
); );

View File

@ -82,7 +82,7 @@ class _LeftBarState extends State<LeftBar>
child: Padding( child: Padding(
padding: EdgeInsets.only(top: 50), padding: EdgeInsets.only(top: 50),
child: InkWell( child: InkWell(
onTap: () => Get.toNamed('/home'), onTap: () => Get.toNamed('/dashboard'),
child: Image.asset( child: Image.asset(
(ThemeCustomizer.instance.theme == ThemeMode.light (ThemeCustomizer.instance.theme == ThemeMode.light
? (widget.isCondensed ? (widget.isCondensed

View File

@ -10,6 +10,11 @@ import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/model/employees/employee_info.dart'; import 'package:marco/model/employees/employee_info.dart';
import 'package:marco/controller/auth/mpin_controller.dart'; import 'package:marco/controller/auth/mpin_controller.dart';
import 'package:marco/view/employees/employee_profile_screen.dart'; import 'package:marco/view/employees/employee_profile_screen.dart';
import 'package:marco/helpers/services/tenant_service.dart';
import 'package:marco/view/tenant/tenant_selection_screen.dart';
import 'package:marco/controller/tenant/tenant_switch_controller.dart';
class UserProfileBar extends StatefulWidget { class UserProfileBar extends StatefulWidget {
final bool isCondensed; final bool isCondensed;
@ -80,6 +85,10 @@ class _UserProfileBarState extends State<UserProfileBar>
_isLoading _isLoading
? const _LoadingSection() ? const _LoadingSection()
: _userProfileSection(isCondensed), : _userProfileSection(isCondensed),
// --- SWITCH TENANT ROW BELOW AVATAR ---
if (!_isLoading && !isCondensed) _switchTenantRow(),
MySpacing.height(12), MySpacing.height(12),
Divider( Divider(
indent: 18, indent: 18,
@ -106,6 +115,127 @@ class _UserProfileBarState extends State<UserProfileBar>
); );
} }
/// Row widget to switch tenant with popup menu (button only)
/// Row widget to switch tenant with popup menu (button only)
Widget _switchTenantRow() {
// Use the dedicated switch controller
final TenantSwitchController tenantSwitchController =
Get.put(TenantSwitchController());
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Obx(() {
if (tenantSwitchController.isLoading.value) {
return _loadingTenantContainer();
}
final tenants = tenantSwitchController.tenants;
if (tenants.isEmpty) return _noTenantContainer();
final selectedTenant = TenantService.currentTenant;
// Sort tenants: selected tenant first
final sortedTenants = List.of(tenants);
if (selectedTenant != null) {
sortedTenants.sort((a, b) {
if (a.id == selectedTenant.id) return -1;
if (b.id == selectedTenant.id) return 1;
return 0;
});
}
return PopupMenuButton<String>(
onSelected: (tenantId) =>
tenantSwitchController.switchTenant(tenantId),
itemBuilder: (_) => sortedTenants.map((tenant) {
return PopupMenuItem(
value: tenant.id,
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 20,
height: 20,
color: Colors.grey.shade200,
child: TenantLogo(logoImage: tenant.logoImage),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
tenant.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: tenant.id == selectedTenant?.id
? FontWeight.bold
: FontWeight.w600,
color: tenant.id == selectedTenant?.id
? Colors.blueAccent
: Colors.black87,
),
),
),
if (tenant.id == selectedTenant?.id)
const Icon(Icons.check_circle,
color: Colors.blueAccent, size: 18),
],
),
);
}).toList(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(Icons.swap_horiz, color: Colors.blue.shade600),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
"Switch Organization",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.blue, fontWeight: FontWeight.bold),
),
),
),
Icon(Icons.arrow_drop_down, color: Colors.blue.shade600),
],
),
),
);
}),
);
}
Widget _loadingTenantContainer() => Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade200, width: 1),
),
child: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
Widget _noTenantContainer() => Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade200, width: 1),
),
child: MyText.bodyMedium(
"No tenants available",
color: Colors.blueAccent,
fontWeight: 600,
),
);
Widget _userProfileSection(bool condensed) { Widget _userProfileSection(bool condensed) {
final padding = MySpacing.fromLTRB( final padding = MySpacing.fromLTRB(
condensed ? 16 : 26, condensed ? 16 : 26,

View File

@ -5,7 +5,6 @@ import 'package:provider/provider.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/extensions/app_localization_delegate.dart'; import 'package:marco/helpers/extensions/app_localization_delegate.dart';
import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/localizations/language.dart'; import 'package:marco/helpers/services/localizations/language.dart';
import 'package:marco/helpers/services/navigation_services.dart'; import 'package:marco/helpers/services/navigation_services.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/storage/local_storage.dart';
@ -19,22 +18,21 @@ class MyApp extends StatelessWidget {
Future<String> _getInitialRoute() async { Future<String> _getInitialRoute() async {
try { try {
if (!AuthService.isLoggedIn) { final token = LocalStorage.getJwtToken();
if (token == null || token.isEmpty) {
logSafe("User not logged in. Routing to /auth/login-option"); logSafe("User not logged in. Routing to /auth/login-option");
return "/auth/login-option"; return "/auth/login-option";
} }
final bool hasMpin = LocalStorage.getIsMpin(); final bool hasMpin = LocalStorage.getIsMpin();
logSafe("MPIN enabled: $hasMpin", );
if (hasMpin) { if (hasMpin) {
await LocalStorage.setBool("mpin_verified", false); await LocalStorage.setBool("mpin_verified", false);
logSafe("Routing to /auth/mpin-auth and setting mpin_verified to false"); logSafe("Routing to /auth/mpin-auth");
return "/auth/mpin-auth"; return "/auth/mpin-auth";
} else {
logSafe("MPIN not enabled. Routing to /dashboard");
return "/dashboard";
} }
logSafe("No MPIN. Routing to /dashboard");
return "/dashboard";
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error determining initial route", logSafe("Error determining initial route",
level: LogLevel.error, error: e, stackTrace: stacktrace); level: LogLevel.error, error: e, stackTrace: stacktrace);

120
lib/view/splash_screen.dart Normal file
View File

@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:marco/images.dart';
class SplashScreen extends StatefulWidget {
final String? message;
final double? logoSize;
final Color? backgroundColor;
const SplashScreen({
super.key,
this.message,
this.logoSize = 120,
this.backgroundColor = Colors.white,
});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
)..repeat(reverse: true);
_animation = Tween<double>(begin: 0.0, end: 8.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Widget _buildAnimatedDots() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(3, (index) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
double opacity;
if (index == 0) {
opacity = (0.3 + _animation.value / 8).clamp(0.0, 1.0);
} else if (index == 1) {
opacity = (0.3 + (_animation.value / 8)).clamp(0.0, 1.0);
} else {
opacity = (0.3 + (1 - _animation.value / 8)).clamp(0.0, 1.0);
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
width: 10,
height: 10,
decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(opacity),
shape: BoxShape.circle,
),
);
},
);
}),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: widget.backgroundColor,
body: SafeArea(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo with slight bounce animation
ScaleTransition(
scale: Tween(begin: 0.8, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
),
),
child: SizedBox(
width: widget.logoSize,
height: widget.logoSize,
child: Image.asset(Images.logoDark),
),
),
const SizedBox(height: 20),
// Text message
if (widget.message != null)
Text(
widget.message!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 30),
// Animated loading dots
_buildAnimatedDots(),
],
),
),
),
);
}
}

View File

@ -39,30 +39,50 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
final DailyTaskController dailyTaskController = final DailyTaskController dailyTaskController =
Get.put(DailyTaskController()); Get.put(DailyTaskController());
final PermissionController permissionController = final PermissionController permissionController =
Get.find<PermissionController>(); Get.put(PermissionController());
final ProjectController projectController = Get.find<ProjectController>(); final ProjectController projectController = Get.find<ProjectController>();
final ScrollController _scrollController = ScrollController();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController.addListener(() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 100 &&
dailyTaskController.hasMore &&
!dailyTaskController.isLoadingMore.value) {
final projectId = dailyTaskController.selectedProjectId;
if (projectId != null && projectId.isNotEmpty) {
dailyTaskController.fetchTaskData(
projectId,
pageNumber: dailyTaskController.currentPage + 1,
pageSize: dailyTaskController.pageSize,
isLoadMore: true,
);
}
}
});
final initialProjectId = projectController.selectedProjectId.value; final initialProjectId = projectController.selectedProjectId.value;
if (initialProjectId.isNotEmpty) { if (initialProjectId.isNotEmpty) {
dailyTaskController.selectedProjectId = initialProjectId; dailyTaskController.selectedProjectId = initialProjectId;
dailyTaskController.fetchTaskData(initialProjectId); dailyTaskController.fetchTaskData(initialProjectId);
} }
ever<String>( // Update when project changes
projectController.selectedProjectId, ever<String>(projectController.selectedProjectId, (newProjectId) async {
(newProjectId) async { if (newProjectId.isNotEmpty &&
if (newProjectId.isNotEmpty && newProjectId != dailyTaskController.selectedProjectId) {
newProjectId != dailyTaskController.selectedProjectId) { dailyTaskController.selectedProjectId = newProjectId;
dailyTaskController.selectedProjectId = newProjectId; await dailyTaskController.fetchTaskData(newProjectId);
await dailyTaskController.fetchTaskData(newProjectId); dailyTaskController.update(['daily_progress_report_controller']);
dailyTaskController.update(['daily_progress_report_controller']); }
} });
}, }
);
@override
void dispose() {
_scrollController.dispose();
super.dispose();
} }
@override @override
@ -131,8 +151,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
child: MyRefreshIndicator( child: MyRefreshIndicator(
onRefresh: _refreshData, onRefresh: _refreshData,
child: CustomScrollView( child: CustomScrollView(
physics: physics: const AlwaysScrollableScrollPhysics(),
const AlwaysScrollableScrollPhysics(),
slivers: [ slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(
child: GetBuilder<DailyTaskController>( child: GetBuilder<DailyTaskController>(
@ -143,7 +162,37 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MySpacing.height(flexSpacing), MySpacing.height(flexSpacing),
_buildActionBar(), Padding(
padding: MySpacing.x(15),
child: Row(
mainAxisAlignment:
MainAxisAlignment.end,
children: [
InkWell(
borderRadius: BorderRadius.circular(22),
onTap: _openFilterSheet,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
child: Row(
children: [
MyText.bodySmall(
"Filter",
fontWeight: 600,
color: Colors.black,
),
const SizedBox(width: 4),
Icon(Icons.tune,
size: 20, color: Colors.black),
],
),
),
),
],
),
),
MySpacing.height(8),
Padding( Padding(
padding: MySpacing.x(8), padding: MySpacing.x(8),
child: _buildDailyProgressReportTab(), child: _buildDailyProgressReportTab(),
@ -160,59 +209,18 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
); );
} }
Widget _buildActionBar() {
return Padding(
padding: MySpacing.x(flexSpacing),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_buildActionItem(
label: "Filter",
icon: Icons.tune,
tooltip: 'Filter Project',
onTap: _openFilterSheet,
),
],
),
);
}
Widget _buildActionItem({
required String label,
required IconData icon,
required String tooltip,
required VoidCallback onTap,
Color? color,
}) {
return Row(
children: [
MyText.bodyMedium(label, fontWeight: 600),
Tooltip(
message: tooltip,
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: onTap,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(icon, color: color, size: 22),
),
),
),
),
],
);
}
Future<void> _openFilterSheet() async { Future<void> _openFilterSheet() async {
final result = await showModalBottomSheet<Map<String, dynamic>>( // Fetch filter data first
if (dailyTaskController.taskFilterData == null) {
await dailyTaskController
.fetchTaskFilter(dailyTaskController.selectedProjectId ?? '');
}
final result = await showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, builder: (context) => DailyTaskFilterBottomSheet(
builder: (context) => DailyProgressReportFilter(
controller: dailyTaskController, controller: dailyTaskController,
permissionController: permissionController,
), ),
); );
@ -299,10 +307,12 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
final isLoading = dailyTaskController.isLoading.value; final isLoading = dailyTaskController.isLoading.value;
final groupedTasks = dailyTaskController.groupedDailyTasks; final groupedTasks = dailyTaskController.groupedDailyTasks;
if (isLoading) { // 🟡 Show loading skeleton on first load
if (isLoading && dailyTaskController.currentPage == 1) {
return SkeletonLoaders.dailyProgressReportSkeletonLoader(); return SkeletonLoaders.dailyProgressReportSkeletonLoader();
} }
// No data available
if (groupedTasks.isEmpty) { if (groupedTasks.isEmpty) {
return Center( return Center(
child: MyText.bodySmall( child: MyText.bodySmall(
@ -312,94 +322,108 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
); );
} }
// 🔽 Sort all date keys by descending (latest first)
final sortedDates = groupedTasks.keys.toList() final sortedDates = groupedTasks.keys.toList()
..sort((a, b) => b.compareTo(a)); ..sort((a, b) => b.compareTo(a));
return MyCard.bordered( // 🔹 Auto expand if only one date present
borderRadiusAll: 10, if (sortedDates.length == 1 &&
border: Border.all(color: Colors.grey.withOpacity(0.2)), !dailyTaskController.expandedDates.contains(sortedDates[0])) {
shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom), dailyTaskController.expandedDates.add(sortedDates[0]);
paddingAll: 8, }
child: ListView.separated(
shrinkWrap: true, // 🧱 Return a scrollable column of cards
physics: const NeverScrollableScrollPhysics(), return Column(
itemCount: sortedDates.length, crossAxisAlignment: CrossAxisAlignment.start,
separatorBuilder: (_, __) => Column( children: [
children: [ ...sortedDates.map((dateKey) {
const SizedBox(height: 12),
Divider(color: Colors.grey.withOpacity(0.3), thickness: 1),
const SizedBox(height: 12),
],
),
itemBuilder: (context, dateIndex) {
final dateKey = sortedDates[dateIndex];
final tasksForDate = groupedTasks[dateKey]!; final tasksForDate = groupedTasks[dateKey]!;
final date = DateTime.tryParse(dateKey); final date = DateTime.tryParse(dateKey);
return Column( return Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.only(bottom: 12),
children: [ child: MyCard.bordered(
GestureDetector( borderRadiusAll: 10,
onTap: () => dailyTaskController.toggleDate(dateKey), border: Border.all(color: Colors.grey.withOpacity(0.2)),
child: Row( shadow:
mainAxisAlignment: MainAxisAlignment.spaceBetween, MyShadow(elevation: 1, position: MyShadowPosition.bottom),
children: [ paddingAll: 12,
MyText.bodyMedium( child: Column(
date != null crossAxisAlignment: CrossAxisAlignment.start,
? DateFormat('dd MMM yyyy').format(date) children: [
: dateKey, // 🗓 Date Header
fontWeight: 700, GestureDetector(
onTap: () => dailyTaskController.toggleDate(dateKey),
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(
date != null
? DateFormat('dd MMM yyyy').format(date)
: dateKey,
fontWeight: 700,
),
Obx(() => Icon(
dailyTaskController.expandedDates
.contains(dateKey)
? Icons.remove_circle
: Icons.add_circle,
color: Colors.blueAccent,
)),
],
),
), ),
Obx(() => Icon( ),
dailyTaskController.expandedDates.contains(dateKey)
? Icons.remove_circle
: Icons.add_circle,
color: Colors.blueAccent,
)),
],
),
),
Obx(() {
if (!dailyTaskController.expandedDates.contains(dateKey)) {
return const SizedBox.shrink();
}
return Column( // 🔽 Task List (expandable)
children: tasksForDate.asMap().entries.map((entry) { Obx(() {
final task = entry.value; if (!dailyTaskController.expandedDates
final index = entry.key; .contains(dateKey)) {
return const SizedBox.shrink();
}
final activityName =
task.workItem?.activityMaster?.activityName ?? 'N/A';
final activityId = task.workItem?.activityMaster?.id;
final workAreaId = task.workItem?.workArea?.id;
final location = [
task.workItem?.workArea?.floor?.building?.name,
task.workItem?.workArea?.floor?.floorName,
task.workItem?.workArea?.areaName
].where((e) => e?.isNotEmpty ?? false).join(' > ');
final planned = task.plannedTask;
final completed = task.completedTask;
final progress = (planned != 0)
? (completed / planned).clamp(0.0, 1.0)
: 0.0;
final parentTaskID = task.id;
return Column( return Column(
children: [ children: tasksForDate.map((task) {
Padding( final activityName =
padding: const EdgeInsets.only(bottom: 8), task.workItem?.activityMaster?.activityName ??
'N/A';
final activityId = task.workItem?.activityMaster?.id;
final workAreaId = task.workItem?.workArea?.id;
final location = [
task.workItem?.workArea?.floor?.building?.name,
task.workItem?.workArea?.floor?.floorName,
task.workItem?.workArea?.areaName
].where((e) => e?.isNotEmpty ?? false).join(' > ');
final planned = task.plannedTask;
final completed = task.completedTask;
final progress = (planned != 0)
? (completed / planned).clamp(0.0, 1.0)
: 0.0;
final parentTaskID = task.id;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: MyContainer( child: MyContainer(
paddingAll: 12, paddingAll: 12,
borderRadiusAll: 8,
border: Border.all(
color: Colors.grey.withOpacity(0.2)),
color: Colors.white,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 🏗 Activity name & location
MyText.bodyMedium(activityName, MyText.bodyMedium(activityName,
fontWeight: 600), fontWeight: 600),
const SizedBox(height: 2), const SizedBox(height: 2),
MyText.bodySmall(location, MyText.bodySmall(location,
color: Colors.grey), color: Colors.grey),
const SizedBox(height: 8), const SizedBox(height: 8),
// 👥 Team Members
GestureDetector( GestureDetector(
onTap: () => _showTeamMembersBottomSheet( onTap: () => _showTeamMembersBottomSheet(
task.teamMembers), task.teamMembers),
@ -408,13 +432,17 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
const Icon(Icons.group, const Icon(Icons.group,
size: 18, color: Colors.blueAccent), size: 18, color: Colors.blueAccent),
const SizedBox(width: 6), const SizedBox(width: 6),
MyText.bodyMedium('Team', MyText.bodyMedium(
color: Colors.blueAccent, 'Team',
fontWeight: 600), color: Colors.blueAccent,
fontWeight: 600,
),
], ],
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// 📊 Progress info
MyText.bodySmall( MyText.bodySmall(
"Completed: $completed / $planned", "Completed: $completed / $planned",
fontWeight: 600, fontWeight: 600,
@ -459,8 +487,12 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
: Colors.red[700], : Colors.red[700],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// 🎯 Action Buttons
SingleChildScrollView( SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(),
primary: false,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
@ -506,21 +538,24 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
], ],
), ),
), ),
), );
if (index != tasksForDate.length - 1) }).toList(),
Divider(
color: Colors.grey.withOpacity(0.2),
thickness: 1,
height: 1),
],
); );
}).toList(), }),
); ],
}) ),
], ),
); );
}, }),
),
// 🔻 Loading More Indicator
Obx(() => dailyTaskController.isLoadingMore.value
? const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
)
: const SizedBox.shrink()),
],
); );
}); });
} }

View File

@ -13,6 +13,8 @@ import 'package:marco/model/dailyTaskPlanning/assign_task_bottom_sheet .dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/controller/tenant/service_controller.dart';
import 'package:marco/helpers/widgets/tenant/service_selector.dart';
class DailyTaskPlanningScreen extends StatefulWidget { class DailyTaskPlanningScreen extends StatefulWidget {
DailyTaskPlanningScreen({super.key}); DailyTaskPlanningScreen({super.key});
@ -29,23 +31,25 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final PermissionController permissionController = final PermissionController permissionController =
Get.put(PermissionController()); Get.put(PermissionController());
final ProjectController projectController = Get.find<ProjectController>(); final ProjectController projectController = Get.find<ProjectController>();
final ServiceController serviceController = Get.put(ServiceController());
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Initial fetch if a project is already selected
final projectId = projectController.selectedProjectId.value; final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) { if (projectId.isNotEmpty) {
dailyTaskPlanningController.fetchTaskData(projectId); dailyTaskPlanningController.fetchTaskData(projectId);
serviceController.fetchServices(projectId);
} }
// Reactive fetch on project ID change // Whenever project changes, fetch tasks & services
ever<String>( ever<String>(
projectController.selectedProjectId, projectController.selectedProjectId,
(newProjectId) { (newProjectId) {
if (newProjectId.isNotEmpty) { if (newProjectId.isNotEmpty) {
dailyTaskPlanningController.fetchTaskData(newProjectId); dailyTaskPlanningController.fetchTaskData(newProjectId);
serviceController.fetchServices(newProjectId);
} }
}, },
); );
@ -119,18 +123,19 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final projectId = projectController.selectedProjectId.value; final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) { if (projectId.isNotEmpty) {
try { try {
await dailyTaskPlanningController.fetchTaskData(projectId); await dailyTaskPlanningController.fetchTaskData(
projectId,
serviceId: serviceController.selectedService?.id,
);
} catch (e) { } catch (e) {
debugPrint('Error refreshing task data: ${e.toString()}'); debugPrint('Error refreshing task data: ${e.toString()}');
} }
} }
}, },
child: SingleChildScrollView( child: SingleChildScrollView(
physics: physics: const AlwaysScrollableScrollPhysics(),
const AlwaysScrollableScrollPhysics(), // <-- always allow drag
padding: MySpacing.x(0), padding: MySpacing.x(0),
child: ConstrainedBox( child: ConstrainedBox(
// <-- ensures full screen height
constraints: BoxConstraints( constraints: BoxConstraints(
minHeight: MediaQuery.of(context).size.height - minHeight: MediaQuery.of(context).size.height -
kToolbarHeight - kToolbarHeight -
@ -143,6 +148,25 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(10),
child: ServiceSelector(
controller: serviceController,
height: 40,
onSelectionChanged: (service) async {
final projectId =
projectController.selectedProjectId.value;
if (projectId.isNotEmpty) {
await dailyTaskPlanningController.fetchTaskData(
projectId,
serviceId:
service?.id, // <-- pass selected service
);
}
},
),
),
MySpacing.height(flexSpacing), MySpacing.height(flexSpacing),
Padding( Padding(
padding: MySpacing.x(8), padding: MySpacing.x(8),
@ -161,7 +185,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
Widget dailyProgressReportTab() { Widget dailyProgressReportTab() {
return Obx(() { return Obx(() {
final isLoading = dailyTaskPlanningController.isLoading.value; final isLoading = dailyTaskPlanningController.isFetchingTasks.value;
final dailyTasks = dailyTaskPlanningController.dailyTasks; final dailyTasks = dailyTaskPlanningController.dailyTasks;
if (isLoading) { if (isLoading) {
@ -265,7 +289,6 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final validWorkAreas = floor.workAreas final validWorkAreas = floor.workAreas
.where((area) => area.workItems.isNotEmpty); .where((area) => area.workItems.isNotEmpty);
// For each valid work area, return a Floor+WorkArea ExpansionTile
return validWorkAreas.map((area) { return validWorkAreas.map((area) {
final floorWorkAreaKey = final floorWorkAreaKey =
"${buildingKey}_${floor.floorName}_${area.areaName}"; "${buildingKey}_${floor.floorName}_${area.areaName}";
@ -279,6 +302,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final totalProgress = totalPlanned == 0 final totalProgress = totalPlanned == 0
? 0.0 ? 0.0
: (totalCompleted / totalPlanned).clamp(0.0, 1.0); : (totalCompleted / totalPlanned).clamp(0.0, 1.0);
return ExpansionTile( return ExpansionTile(
onExpansionChanged: (expanded) { onExpansionChanged: (expanded) {
setMainState(() { setMainState(() {
@ -330,7 +354,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
percent: totalProgress, percent: totalProgress,
center: Text( center: Text(
"${(totalProgress * 100).toStringAsFixed(0)}%", "${(totalProgress * 100).toStringAsFixed(0)}%",
style: TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 10.0, fontSize: 10.0,
), ),
@ -416,7 +440,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
permissionController.hasPermission( permissionController.hasPermission(
Permissions.assignReportTask)) Permissions.assignReportTask))
IconButton( IconButton(
icon: Icon( icon: const Icon(
Icons.person_add_alt_1_rounded, Icons.person_add_alt_1_rounded,
color: color:
Color.fromARGB(255, 46, 161, 233), Color.fromARGB(255, 46, 161, 233),
@ -480,7 +504,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
), ),
], ],
), ),
SizedBox(height: 4), const SizedBox(height: 4),
MyText.bodySmall( MyText.bodySmall(
"${(progress * 100).toStringAsFixed(1)}%", "${(progress * 100).toStringAsFixed(1)}%",
fontWeight: 500, fontWeight: 500,

View File

@ -0,0 +1,389 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_endpoints.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/images.dart';
import 'package:marco/controller/tenant/tenant_selection_controller.dart';
import 'package:marco/view/splash_screen.dart';
class TenantSelectionScreen extends StatefulWidget {
const TenantSelectionScreen({super.key});
@override
State<TenantSelectionScreen> createState() => _TenantSelectionScreenState();
}
class _TenantSelectionScreenState extends State<TenantSelectionScreen>
with UIMixin, SingleTickerProviderStateMixin {
late final TenantSelectionController _controller;
late final AnimationController _logoAnimController;
late final Animation<double> _logoAnimation;
final bool _isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage");
@override
void initState() {
super.initState();
_controller = Get.put(TenantSelectionController());
_logoAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_logoAnimation = CurvedAnimation(
parent: _logoAnimController,
curve: Curves.easeOutBack,
);
_logoAnimController.forward();
}
@override
void dispose() {
_logoAnimController.dispose();
Get.delete<TenantSelectionController>();
super.dispose();
}
Future<void> _onTenantSelected(String tenantId) async {
await _controller.onTenantSelected(tenantId);
}
@override
Widget build(BuildContext context) {
return Obx(() {
// Splash screen for auto-selection
if (_controller.isAutoSelecting.value) {
return const SplashScreen();
}
return Scaffold(
body: Stack(
children: [
_RedWaveBackground(brandRed: contentTheme.brandRed),
SafeArea(
child: Center(
child: Column(
children: [
const SizedBox(height: 24),
_AnimatedLogo(animation: _logoAnimation),
const SizedBox(height: 8),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
children: [
const SizedBox(height: 12),
const _WelcomeTexts(),
if (_isBetaEnvironment) ...[
const SizedBox(height: 12),
const _BetaBadge(),
],
const SizedBox(height: 36),
TenantCardList(
controller: _controller,
isLoading: _controller.isLoading.value,
onTenantSelected: _onTenantSelected,
),
],
),
),
),
),
),
],
),
),
),
],
),
);
});
}
}
/// Animated Logo Widget
class _AnimatedLogo extends StatelessWidget {
final Animation<double> animation;
const _AnimatedLogo({required this.animation});
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: animation,
child: Container(
width: 100,
height: 100,
padding: const EdgeInsets.all(20),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 10,
offset: Offset(0, 4),
),
],
),
child: Image.asset(Images.logoDark),
),
);
}
}
/// Welcome Texts
class _WelcomeTexts extends StatelessWidget {
const _WelcomeTexts();
@override
Widget build(BuildContext context) {
return Column(
children: [
MyText(
"Welcome",
fontSize: 24,
fontWeight: 600,
color: Colors.black87,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
MyText(
"Please select which dashboard you want to explore!",
fontSize: 14,
color: Colors.black54,
textAlign: TextAlign.center,
),
],
);
}
}
/// Beta Badge
class _BetaBadge extends StatelessWidget {
const _BetaBadge();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.orangeAccent,
borderRadius: BorderRadius.circular(5),
),
child: MyText(
'BETA',
color: Colors.white,
fontWeight: 600,
fontSize: 12,
),
);
}
}
/// Tenant Card List
class TenantCardList extends StatelessWidget {
final TenantSelectionController controller;
final bool isLoading;
final Function(String tenantId) onTenantSelected;
const TenantCardList({
required this.controller,
required this.isLoading,
required this.onTenantSelected,
});
@override
Widget build(BuildContext context) {
return Obx(() {
if (controller.isLoading.value || isLoading) {
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
}
final hasTenants = controller.tenants.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (!hasTenants) ...[
MyText(
"No dashboards available for your account.",
fontSize: 14,
color: Colors.black54,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
],
if (hasTenants) ...controller.tenants.map(
(tenant) => _TenantCard(
tenant: tenant,
onTap: () => onTenantSelected(tenant.id),
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () async {
await LocalStorage.logout();
},
icon: const Icon(Icons.arrow_back, size: 20, color: Colors.redAccent),
label: MyText(
'Back to Login',
color: Colors.red,
fontWeight: 600,
fontSize: 14,
),
),
],
);
});
}
}
/// Single Tenant Card
class _TenantCard extends StatelessWidget {
final dynamic tenant;
final VoidCallback onTap;
const _TenantCard({required this.tenant, required this.onTap});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(5),
child: Card(
elevation: 3,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
margin: const EdgeInsets.only(bottom: 20),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: Container(
width: 60,
height: 60,
color: Colors.grey.shade200,
child: TenantLogo(logoImage: tenant.logoImage),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText(
tenant.name,
fontSize: 18,
fontWeight: 700,
color: Colors.black87,
),
const SizedBox(height: 6),
MyText(
"Industry: ${tenant.industry?.name ?? "-"}",
fontSize: 13,
color: Colors.black54,
),
],
),
),
const Icon(Icons.arrow_forward_ios, size: 24, color: Colors.red),
],
),
),
),
);
}
}
/// Tenant Logo (supports base64 and URL)
class TenantLogo extends StatelessWidget {
final String? logoImage;
const TenantLogo({required this.logoImage});
@override
Widget build(BuildContext context) {
if (logoImage == null || logoImage!.isEmpty) {
return Center(child: Icon(Icons.business, color: Colors.grey.shade600));
}
if (logoImage!.startsWith("data:image")) {
try {
final base64Str = logoImage!.split(',').last;
final bytes = base64Decode(base64Str);
return Image.memory(bytes, fit: BoxFit.cover);
} catch (_) {
return Center(child: Icon(Icons.business, color: Colors.grey.shade600));
}
} else {
return Image.network(
logoImage!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Center(
child: Icon(Icons.business, color: Colors.grey.shade600),
),
);
}
}
}
/// Red Wave Background
class _RedWaveBackground extends StatelessWidget {
final Color brandRed;
const _RedWaveBackground({required this.brandRed});
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _WavePainter(brandRed),
size: Size.infinite,
);
}
}
class _WavePainter extends CustomPainter {
final Color brandRed;
_WavePainter(this.brandRed);
@override
void paint(Canvas canvas, Size size) {
final paint1 = Paint()
..shader = LinearGradient(
colors: [brandRed, const Color.fromARGB(255, 97, 22, 22)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
final path1 = Path()
..moveTo(0, size.height * 0.2)
..quadraticBezierTo(
size.width * 0.25, size.height * 0.05, size.width * 0.5, size.height * 0.15)
..quadraticBezierTo(
size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1)
..lineTo(size.width, 0)
..lineTo(0, 0)
..close();
canvas.drawPath(path1, paint1);
final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15);
final path2 = Path()
..moveTo(0, size.height * 0.25)
..quadraticBezierTo(size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2)
..lineTo(size.width, 0)
..lineTo(0, 0)
..close();
canvas.drawPath(path2, paint2);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}