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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ class AddContactController extends GetxController {
final RxList<String> tags = <String>[].obs;
final RxString selectedCategory = ''.obs;
final RxString selectedBucket = ''.obs;
final RxList<String> selectedBuckets = <String>[].obs;
final RxString selectedProject = ''.obs;
final RxList<String> enteredTags = <String>[].obs;
@ -50,7 +50,7 @@ class AddContactController extends GetxController {
void resetForm() {
selectedCategory.value = '';
selectedProject.value = '';
selectedBucket.value = '';
selectedBuckets.clear();
enteredTags.clear();
filteredSuggestions.clear();
filteredOrgSuggestions.clear();
@ -94,12 +94,27 @@ class AddContactController extends GetxController {
required List<Map<String, String>> phones,
required String address,
required String description,
String? designation,
}) async {
if (isSubmitting.value) return;
isSubmitting.value = true;
final categoryId = categoriesMap[selectedCategory.value];
final 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
.map((name) => projectsMap[name])
.whereType<String>()
@ -125,10 +140,10 @@ class AddContactController extends GetxController {
return;
}
if (selectedBucket.value.trim().isEmpty || bucketId == null) {
if (selectedBuckets.isEmpty) {
showAppSnackbar(
title: "Missing Bucket",
message: "Please select a bucket.",
message: "Please select at least one bucket.",
type: SnackbarType.warning,
);
isSubmitting.value = false;
@ -150,12 +165,14 @@ class AddContactController extends GetxController {
if (selectedCategory.value.isNotEmpty && categoryId != null)
"contactCategoryId": categoryId,
if (projectIds.isNotEmpty) "projectIds": projectIds,
"bucketIds": [bucketId],
"bucketIds": bucketIds,
if (enteredTags.isNotEmpty) "tags": tagObjects,
if (emails.isNotEmpty) "contactEmails": emails,
if (phones.isNotEmpty) "contactPhones": phones,
if (address.trim().isNotEmpty) "address": address.trim(),
if (description.trim().isNotEmpty) "description": description.trim(),
if (designation != null && designation.trim().isNotEmpty)
"designation": designation.trim(),
};
logSafe("${id != null ? 'Updating' : 'Creating'} contact");

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -9,34 +9,28 @@ import 'package:marco/model/employees/employee_model.dart';
class DailyTaskPlanningController extends GetxController {
List<ProjectModel> projects = [];
List<EmployeeModel> employees = [];
List<TaskPlanningDetailsModel> dailyTasks = [];
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs;
List<EmployeeModel> allEmployeesCache = [];
List<TaskPlanningDetailsModel> dailyTasks = [];
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
MyFormValidator basicValidator = MyFormValidator();
List<Map<String, dynamic>> roles = [];
RxBool isAssigningTask = false.obs;
RxnString selectedRoleId = RxnString();
RxBool isLoading = false.obs;
RxBool isFetchingTasks = true.obs;
RxBool isFetchingProjects = true.obs;
RxBool isFetchingEmployees = true.obs;
@override
void onInit() {
super.onInit();
fetchRoles();
_initializeDefaults();
}
void _initializeDefaults() {
fetchProjects();
}
String? formFieldValidator(String? value, {required String fieldType}) {
if (value == null || value.trim().isEmpty) {
return 'This field is required';
}
if (value == null || value.trim().isEmpty) return 'This field is required';
if (fieldType == "target" && int.tryParse(value.trim()) == null) {
return 'Please enter a valid number';
}
@ -47,21 +41,14 @@ class DailyTaskPlanningController extends GetxController {
}
void updateSelectedEmployees() {
final selected =
selectedEmployees.value =
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) {
selectedRoleId.value = roleId;
logSafe(
"Role selected",
level: LogLevel.info,
);
logSafe("Role selected", level: LogLevel.info);
}
Future<void> fetchRoles() async {
@ -82,6 +69,8 @@ class DailyTaskPlanningController extends GetxController {
required String description,
required List<String> taskTeam,
DateTime? assignmentDate,
String? organizationId,
String? serviceId,
}) async {
isAssigningTask.value = true;
logSafe("Starting assign task...", level: LogLevel.info);
@ -92,6 +81,8 @@ class DailyTaskPlanningController extends GetxController {
description: description,
taskTeam: taskTeam,
assignmentDate: assignmentDate,
organizationId: organizationId,
serviceId: serviceId,
);
isAssigningTask.value = false;
@ -115,92 +106,158 @@ class DailyTaskPlanningController extends GetxController {
}
}
Future<void> fetchProjects() async {
isLoading.value = true;
/// Fetch Infra details and then tasks per work area
Future<void> fetchTaskData(String? projectId, {String? serviceId}) async {
if (projectId == null) return;
isFetchingTasks.value = true;
try {
final response = await ApiService.getProjects();
if (response?.isEmpty ?? true) {
logSafe("No project data found or API call failed",
level: LogLevel.warning);
final infraResponse = await ApiService.getInfraDetails(
projectId,
serviceId: serviceId,
);
final infraData = infraResponse?['data'] as List<dynamic>?;
if (infraData == null || infraData.isEmpty) {
dailyTasks = [];
return;
}
projects = response!.map((json) => ProjectModel.fromJson(json)).toList();
logSafe("Projects fetched: ${projects.length} projects loaded",
level: LogLevel.info);
update();
} catch (e, stack) {
logSafe("Error fetching projects",
level: LogLevel.error, error: e, stackTrace: stack);
} finally {
isLoading.value = false;
}
}
Future<void> fetchTaskData(String? projectId) async {
if (projectId == null) {
logSafe("Project ID is null", level: LogLevel.warning);
return;
}
isLoading.value = true;
try {
final response = await ApiService.getDailyTasksDetails(projectId);
final data = response?['data'];
if (data != null) {
dailyTasks = [TaskPlanningDetailsModel.fromJson(data)];
logSafe(
"Daily task Planning Details fetched",
level: LogLevel.info,
dailyTasks = infraData.map((buildingJson) {
final building = Building(
id: buildingJson['id'],
name: buildingJson['buildingName'],
description: buildingJson['description'],
floors: (buildingJson['floors'] as List<dynamic>)
.map((floorJson) => Floor(
id: floorJson['id'],
floorName: floorJson['floorName'],
workAreas: (floorJson['workAreas'] as List<dynamic>)
.map((areaJson) => WorkArea(
id: areaJson['id'],
areaName: areaJson['areaName'],
workItems: [],
))
.toList(),
))
.toList(),
);
} else {
logSafe("Data field is null", level: LogLevel.warning);
}
return TaskPlanningDetailsModel(
id: building.id,
name: building.name,
projectAddress: "",
contactPerson: "",
startDate: DateTime.now(),
endDate: DateTime.now(),
projectStatusId: "",
buildings: [building],
);
}).toList();
await Future.wait(dailyTasks
.expand((task) => task.buildings)
.expand((b) => b.floors)
.expand((f) => f.workAreas)
.map((area) async {
try {
final taskResponse =
await ApiService.getWorkItemsByWorkArea(area.id, serviceId: serviceId);
final taskData = taskResponse?['data'] as List<dynamic>? ?? [];
area.workItems.addAll(taskData.map((taskJson) => WorkItemWrapper(
workItemId: taskJson['id'],
workItem: WorkItem(
id: taskJson['id'],
activityMaster: taskJson['activityMaster'] != null
? ActivityMaster.fromJson(taskJson['activityMaster'])
: null,
workCategoryMaster: taskJson['workCategoryMaster'] != null
? WorkCategoryMaster.fromJson(taskJson['workCategoryMaster'])
: null,
plannedWork: (taskJson['plannedWork'] as num?)?.toDouble(),
completedWork: (taskJson['completedWork'] as num?)?.toDouble(),
todaysAssigned: (taskJson['todaysAssigned'] as num?)?.toDouble(),
description: taskJson['description'] as String?,
taskDate: taskJson['taskDate'] != null
? DateTime.tryParse(taskJson['taskDate'])
: null,
),
)));
} catch (e, stack) {
logSafe("Error fetching tasks for work area ${area.id}",
level: LogLevel.error, error: e, stackTrace: stack);
}
}));
} catch (e, stack) {
logSafe("Error fetching daily task data",
level: LogLevel.error, error: e, stackTrace: stack);
} finally {
isLoading.value = false;
isFetchingTasks.value = false;
update();
}
}
Future<void> fetchEmployeesByProject(String? projectId) async {
if (projectId == null || projectId.isEmpty) {
logSafe("Project ID is required but was null or empty",
level: LogLevel.error);
return;
}
Future<void> fetchEmployeesByProjectService({
required String projectId,
String? serviceId,
String? organizationId,
}) async {
isFetchingEmployees.value = true;
isLoading.value = true;
try {
final response = await ApiService.getAllEmployeesByProject(projectId);
final response = await ApiService.getEmployeesByProjectService(
projectId,
serviceId: serviceId ?? '',
organizationId: organizationId ?? '',
);
if (response != null && response.isNotEmpty) {
employees =
response.map((json) => EmployeeModel.fromJson(json)).toList();
for (var emp in employees) {
uploadingStates[emp.id] = false.obs;
employees.assignAll(response.map((json) => EmployeeModel.fromJson(json)));
if (serviceId == null && organizationId == null) {
allEmployeesCache = List.from(employees);
}
logSafe(
"Employees fetched: ${employees.length} for project $projectId",
level: LogLevel.info,
);
final currentEmployeeIds = employees.map((e) => e.id).toSet();
uploadingStates.removeWhere((key, _) => !currentEmployeeIds.contains(key));
employees.forEach((emp) {
uploadingStates.putIfAbsent(emp.id, () => false.obs);
});
selectedEmployees.removeWhere((e) => !currentEmployeeIds.contains(e.id));
logSafe("Employees fetched: ${employees.length}", level: LogLevel.info);
} else {
employees = [];
employees.clear();
uploadingStates.clear();
selectedEmployees.clear();
logSafe(
"No employees found for project $projectId",
serviceId != null || organizationId != null
? "Filtered employees empty"
: "No employees found",
level: LogLevel.warning,
);
}
} catch (e, stack) {
logSafe(
"Error fetching employees for project $projectId",
level: LogLevel.error,
error: e,
stackTrace: stack,
);
logSafe("Error fetching employees", level: LogLevel.error, error: e, stackTrace: stack);
if (serviceId == null && organizationId == null && allEmployeesCache.isNotEmpty) {
employees.assignAll(allEmployeesCache);
final cachedEmployeeIds = employees.map((e) => e.id).toSet();
uploadingStates.removeWhere((key, _) => !cachedEmployeeIds.contains(key));
employees.forEach((emp) {
uploadingStates.putIfAbsent(emp.id, () => false.obs);
});
selectedEmployees.removeWhere((e) => !cachedEmployeeIds.contains(e.id));
} else {
employees.clear();
uploadingStates.clear();
selectedEmployees.clear();
}
} finally {
isLoading.value = false;
isFetchingEmployees.value = false;
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 {
// static const String baseUrl = "https://stageapi.marcoaiot.com/api";
static const String baseUrl = "https://api.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://devapi.marcoaiot.com/api";
// Dashboard Module API Endpoints
@ -14,7 +14,7 @@ class ApiEndpoints {
// Attendance Module API Endpoints
static const String getProjects = "/project/list";
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 getAttendanceLogView = "/attendance/log/attendance";
static const String getRegularizationLogs = "/attendance/regularize";
@ -22,10 +22,11 @@ class ApiEndpoints {
// Employee Screen API Endpoints
static const String getAllEmployeesByProject = "/employee/list";
static const String getAllEmployeesByOrganization = "/project/get/task/team";
static const String getAllEmployees = "/employee/list";
static const String getEmployeesWithoutPermission = "/employee/basic";
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 assignEmployee = "/employee/profile/get";
static const String getAssignedProjects = "/project/assigned-projects";
@ -41,6 +42,7 @@ class ApiEndpoints {
static const String approveReportAction = "/task/approve";
static const String assignTask = "/project/task";
static const String getmasterWorkCategories = "/Master/work-categories";
static const String getDailyTaskProjectProgressFilter = "/task/filter";
////// Directory Module API Endpoints ///////
static const String getDirectoryContacts = "/directory";
@ -52,6 +54,8 @@ class ApiEndpoints {
static const String getDirectoryOrganization = "/directory/organization";
static const String createContact = "/directory";
static const String updateContact = "/directory";
static const String deleteContact = "/directory";
static const String restoreContact = "/directory/note";
static const String getDirectoryNotes = "/directory/notes";
static const String updateDirectoryNotes = "/directory/note";
static const String createBucket = "/directory/bucket";
@ -90,4 +94,10 @@ class ApiEndpoints {
/// Logs Module API Endpoints
static const String uploadLogs = "/log";
static const String getAssignedOrganizations =
"/project/get/assigned/organization";
static const getAllOrganizations = "/organization/list";
static const String getAssignedServices = "/Project/get/assigned/services";
}

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/document_details_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 {
static const Duration timeout = Duration(seconds: 30);
static const bool enableLogs = true;
static const Duration extendedTimeout = Duration(seconds: 60);
@ -137,8 +141,9 @@ class ApiService {
logSafe("Headers: ${_headers(token)}", level: LogLevel.debug);
try {
final response =
await http.get(uri, headers: _headers(token)).timeout(timeout);
final response = await http
.get(uri, headers: _headers(token))
.timeout(extendedTimeout);
logSafe("Response Status: ${response.statusCode}", level: LogLevel.debug);
logSafe("Response Body: ${response.body}", level: LogLevel.debug);
@ -172,7 +177,7 @@ class ApiService {
static Future<http.Response?> _postRequest(
String endpoint,
dynamic body, {
Duration customTimeout = timeout,
Duration customTimeout = extendedTimeout,
bool hasRetried = false,
}) async {
String? token = await _getToken();
@ -206,7 +211,7 @@ class ApiService {
String endpoint,
dynamic body, {
Map<String, String>? additionalHeaders,
Duration customTimeout = timeout,
Duration customTimeout = extendedTimeout,
bool hasRetried = false,
}) async {
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 {
const endpoint = "${ApiEndpoints.uploadLogs}";
logSafe("Posting logs... count=${logs.length}");
try {
final response =
await _postRequest(endpoint, logs, customTimeout: extendedTimeout);
if (response == null) {
logSafe("Post logs failed: null response", level: LogLevel.error);
// Get token directly without triggering logout or refresh
final token = await LocalStorage.getJwtToken();
if (token == null) {
logSafe("No token available. Skipping logs post.",
level: LogLevel.warning);
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 body: ${response.body}");
@ -868,8 +1009,9 @@ class ApiService {
logSafe("Sending DELETE request to $uri", level: LogLevel.debug);
final response =
await http.delete(uri, headers: _headers(token)).timeout(timeout);
final response = await http
.delete(uri, headers: _headers(token))
.timeout(extendedTimeout);
logSafe("DELETE expense response status: ${response.statusCode}");
logSafe("DELETE expense response body: ${response.body}");
@ -1281,8 +1423,9 @@ class ApiService {
logSafe("Sending DELETE request to $uri", level: LogLevel.debug);
final response =
await http.delete(uri, headers: _headers(token)).timeout(timeout);
final response = await http
.delete(uri, headers: _headers(token))
.timeout(extendedTimeout);
logSafe("DELETE bucket response status: ${response.statusCode}");
logSafe("DELETE bucket response body: ${response.body}");
@ -1615,8 +1758,53 @@ class ApiService {
return false;
}
static Future<List<dynamic>?> getDirectoryComments(String contactId) async {
final url = "${ApiEndpoints.getDirectoryNotes}/$contactId";
static Future<bool> restoreContactComment(
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 data = response != null
? _parseResponse(response, label: 'Directory Comments')
@ -1625,6 +1813,52 @@ class ApiService {
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(
String contactId, Map<String, dynamic> payload) async {
try {
@ -1733,23 +1967,49 @@ class ApiService {
_getRequest(ApiEndpoints.getGlobalProjects).then((res) =>
res != null ? _parseResponse(res, label: 'Global Projects') : null);
static Future<List<dynamic>?> getEmployeesByProject(String projectId) async =>
_getRequest(ApiEndpoints.getEmployeesByProject,
queryParams: {"projectId": projectId})
.then((res) =>
res != null ? _parseResponse(res, label: 'Employees') : null);
static Future<List<dynamic>?> getTodaysAttendance(
String projectId, {
String? organizationId,
}) async {
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(
String projectId, {
DateTime? dateFrom,
DateTime? dateTo,
String? organizationId,
}) async {
final query = {
"projectId": projectId,
if (dateFrom != null)
"dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom),
if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo),
if (organizationId != null) "organizationId": organizationId,
};
return _getRequest(ApiEndpoints.getAttendanceLogs, queryParams: query).then(
(res) =>
res != null ? _parseResponse(res, label: 'Attendance Logs') : null);
@ -1759,13 +2019,6 @@ class ApiService {
_getRequest("${ApiEndpoints.getAttendanceLogView}/$id").then((res) =>
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(
String id,
String employeeId,
@ -1859,11 +2112,15 @@ class ApiService {
return null;
}
static Future<List<dynamic>?> getAllEmployeesByProject(
String projectId) async {
static Future<List<dynamic>?> getAllEmployeesByProject(String projectId,
{String? organizationId}) async {
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(
(res) => res != null
@ -1872,28 +2129,78 @@ class ApiService {
);
}
static Future<List<dynamic>?> getAllEmployees() async =>
_getRequest(ApiEndpoints.getAllEmployees).then((res) =>
res != null ? _parseResponse(res, label: 'All Employees') : null);
/// Fetches employees by projectId, serviceId, and organizationId
static Future<List<dynamic>?> getEmployeesByProjectService(
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 =>
_getRequest(ApiEndpoints.getRoles).then(
(res) => res != null ? _parseResponse(res, label: 'Roles') : null);
static Future<Map<String, dynamic>?> createEmployee({
String? id,
required String firstName,
required String lastName,
required String phoneNumber,
required String gender,
required String jobRoleId,
required String joiningDate,
String? email,
String? organizationId,
bool? hasApplicationAccess,
}) async {
final body = {
if (id != null) "id": id,
"firstName": firstName,
"lastName": lastName,
"phoneNumber": phoneNumber,
"gender": gender,
"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(
@ -1907,7 +2214,7 @@ class ApiService {
final json = jsonDecode(response.body);
return {
"success": response.statusCode == 200 && json['success'] == true,
"data": json
"data": json,
};
}
@ -1922,21 +2229,66 @@ class ApiService {
}
// === 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, {
DateTime? dateFrom,
DateTime? dateTo,
Map<String, dynamic>? filter,
int pageNumber = 1,
int pageSize = 20,
}) async {
// Build query parameters
final query = {
"projectId": projectId,
if (dateFrom != null)
"dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom),
if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo),
"pageNumber": pageNumber.toString(),
"pageSize": pageSize.toString(),
if (filter != null) "filter": jsonEncode(filter),
};
return _getRequest(ApiEndpoints.getDailyTask, queryParams: query).then(
(res) =>
res != null ? _parseResponse(res, label: 'Daily Tasks') : null);
final uri =
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({
@ -1989,14 +2341,44 @@ class ApiService {
return response.statusCode == 200 && json['success'] == true;
}
static Future<Map<String, dynamic>?> getDailyTasksDetails(
String projectId) async {
final url = "${ApiEndpoints.dailyTaskDetails}/$projectId";
final response = await _getRequest(url);
return response != null
? _parseResponseForAllData(response, label: 'Daily Task Details')
as Map<String, dynamic>?
: null;
/// Fetch infra details for a project, optionally filtered by service
static Future<Map<String, dynamic>?> getInfraDetails(String projectId,
{String? serviceId}) async {
String endpoint = "/project/infra-details/$projectId";
if (serviceId != null && serviceId.isNotEmpty) {
endpoint += "?serviceId=$serviceId";
}
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({
@ -2005,12 +2387,16 @@ class ApiService {
required String description,
required List<String> taskTeam,
DateTime? assignmentDate,
String? organizationId,
String? serviceId,
}) async {
final body = {
"workItemId": workItemId,
"plannedTask": plannedTask,
"description": description,
"taskTeam": taskTeam,
"organizationId": organizationId,
"serviceId": serviceId,
"assignmentDate":
(assignmentDate ?? DateTime.now()).toUtc().toIso8601String(),
};

View File

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

View File

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

View File

@ -19,7 +19,7 @@ class FirebaseNotificationService {
_registerMessageListeners();
_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);
}
@ -49,6 +49,7 @@ class FirebaseNotificationService {
FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
// Background messages
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
}
@ -111,8 +112,6 @@ class FirebaseNotificationService {
}
}
/// Handle tap on notification
void _handleNotificationTap(RemoteMessage message) {
_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 {
final 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/document/user_document_controller.dart';
import 'package:marco/controller/document/document_details_controller.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
/// Handles incoming FCM notification actions and updates UI/controllers.
class NotificationActionHandler {
@ -45,6 +47,10 @@ class NotificationActionHandler {
break;
case 'attendance_updated':
_handleAttendanceUpdated(data);
_handleDashboardUpdate(data); // refresh dashboard attendance
break;
case 'dashboard_update':
_handleDashboardUpdate(data); // full dashboard refresh
break;
default:
_logger.w('⚠️ Unknown notification type: $type');
@ -59,16 +65,23 @@ class NotificationActionHandler {
case 'Attendance':
if (_isAttendanceAction(action)) {
_handleAttendanceUpdated(data);
_handleDashboardUpdate(data);
}
break;
case 'Team_Modified':
// Call method to handle team modifications and dashboard update
_handleDashboardUpdate(data);
break;
/// 🔹 Tasks
case 'Report_Task':
_handleTaskUpdated(data, isComment: false);
_handleDashboardUpdate(data);
break;
case 'Task_Comment':
_handleTaskUpdated(data, isComment: true);
_handleDashboardUpdate(data);
break;
case 'Task_Modified':
@ -76,11 +89,13 @@ class NotificationActionHandler {
case 'Floor_Modified':
case 'Building_Modified':
_handleTaskPlanningUpdated(data);
_handleDashboardUpdate(data);
break;
/// 🔹 Expenses
case 'Expenses_Modified':
_handleExpenseUpdated(data);
_handleDashboardUpdate(data);
break;
/// 🔹 Documents
@ -198,77 +213,99 @@ class NotificationActionHandler {
/// ---------------------- DOCUMENT HANDLER ----------------------
static void _handleDocumentModified(Map<String, dynamic> data) {
final entityTypeId = data['EntityTypeId'];
final entityId = data['EntityId'];
String entityTypeId;
String entityId;
String? documentId = data['DocumentId'];
if (entityTypeId == null || entityId == null) {
_logger.w(
"⚠️ Document update received without EntityTypeId/EntityId: $data");
// Determine entity type and ID
if (data['Keyword'] == 'Employee_Document_Modified') {
entityTypeId = Permissions.employeeEntity;
entityId = data['EmployeeId'] ?? '';
} else if (data['Keyword'] == 'Project_Document_Modified') {
entityTypeId = Permissions.projectEntity;
entityId = data['ProjectId'] ?? '';
} else {
_logger.w("⚠️ Document update received with unknown keyword: $data");
return;
}
// Refresh document list
_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.',
);
if (entityId.isEmpty) {
_logger.w("⚠️ Document update missing entityId: $data");
return;
}
// Refresh document details (if open and matches)
// Refresh document details (if open and matches)
final documentId = data['DocumentId'];
if (documentId != null) {
_logger.i(
"🔔 Document notification received: keyword=${data['Keyword']}, entityTypeId=$entityTypeId, entityId=$entityId, documentId=$documentId");
// Refresh Document List
if (Get.isRegistered<DocumentController>()) {
_safeControllerUpdate<DocumentController>(
onFound: (controller) async {
await controller.fetchDocuments(
entityTypeId: entityTypeId,
entityId: entityId,
reset: true,
);
},
notFoundMessage:
'⚠️ DocumentController not found, cannot refresh list.',
successMessage: '✅ DocumentController refreshed from notification.',
);
} else {
_logger.w('⚠️ DocumentController not registered, skipping list refresh.');
}
// Refresh Document Details (if open)
if (documentId != null && Get.isRegistered<DocumentDetailsController>()) {
_safeControllerUpdate<DocumentDetailsController>(
onFound: (controller) async {
if (controller.documentDetails.value?.data?.id == documentId) {
await controller.fetchDocumentDetails(documentId);
_logger.i(
"✅ DocumentDetailsController refreshed for Document $documentId");
}
// Refresh details regardless of current document
await controller.fetchDocumentDetails(documentId);
_logger.i(
"✅ DocumentDetailsController refreshed for Document $documentId");
},
notFoundMessage: ' DocumentDetailsController not active, skipping.',
notFoundMessage:
' DocumentDetailsController not active, skipping details refresh.',
successMessage: '✅ DocumentDetailsController checked for refresh.',
);
} else if (documentId != null) {
_logger.w(
'⚠️ DocumentDetailsController not registered, cannot refresh document details.');
}
}
/// ---------------------- DIRECTORY HANDLERS ----------------------
static void _handleContactModified(Map<String, dynamic> data) {
_safeControllerUpdate<DirectoryController>(
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'];
final contactId = data['ContactId'];
// Always refresh the contact list
_safeControllerUpdate<DirectoryController>(
onFound: (controller) {
controller.fetchContacts();
// If a specific contact is provided, refresh its notes as well
if (contactId != null) {
controller.fetchCommentsForContact(contactId);
}
},
notFoundMessage:
'⚠️ DirectoryController not found, cannot refresh notes.',
successMessage: '✅ Directory comments refreshed from notification.',
'⚠️ DirectoryController not found, cannot refresh contacts.',
successMessage:
'✅ Directory contacts (and notes if applicable) refreshed from notification.',
);
// Refresh notes globally as well
_safeControllerUpdate<NotesController>(
onFound: (controller) => controller.fetchNotes(),
notFoundMessage: '⚠️ NotesController not found, cannot refresh.',
notFoundMessage: '⚠️ NotesController not found, cannot refresh notes.',
successMessage: '✅ Notes refreshed from notification.',
);
}
static void _handleContactNoteModified(Map<String, dynamic> data) {
// Refresh both contacts and notes when a note is modified
_handleContactModified(data);
}
static void _handleBucketModified(Map<String, dynamic> data) {
_safeControllerUpdate<DirectoryController>(
onFound: (controller) => controller.fetchBuckets(),
@ -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 ----------------------
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';
class PermissionService {
// In-memory cache keyed by user token
static final Map<String, Map<String, dynamic>> _userDataCache = {};
static const String _baseUrl = ApiEndpoints.baseUrl;
/// Fetches all user-related data (permissions, employee info, projects)
/// 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(
String token, {
bool hasRetried = false,
}) async {
logSafe("Fetching user data...", );
logSafe("Fetching user data...");
if (_userDataCache.containsKey(token)) {
logSafe("User data cache hit.", );
return _userDataCache[token]!;
// Check for cached data before network request
final cached = _userDataCache[token];
if (cached != null) {
logSafe("User data cache hit.");
return cached;
}
final uri = Uri.parse("$_baseUrl/user/profile");
@ -34,8 +38,8 @@ class PermissionService {
final statusCode = response.statusCode;
if (statusCode == 200) {
logSafe("User data fetched successfully.");
final data = json.decode(response.body)['data'];
final raw = json.decode(response.body);
final data = raw['data'] as Map<String, dynamic>;
final result = {
'permissions': _parsePermissions(data['featurePermissions']),
@ -43,10 +47,12 @@ class PermissionService {
'projects': _parseProjectsInfo(data['projects']),
};
_userDataCache[token] = result;
_userDataCache[token] = result; // Cache it for future use
logSafe("User data fetched successfully.");
return result;
}
// Token expired, try refresh once then redirect on failure
if (statusCode == 401 && !hasRetried) {
logSafe("Unauthorized. Attempting token refresh...", level: LogLevel.warning);
@ -63,42 +69,43 @@ class PermissionService {
throw Exception('Unauthorized. Token refresh failed.');
}
final error = json.decode(response.body)['message'] ?? 'Unknown error';
logSafe("Failed to fetch user data: $error", level: LogLevel.warning);
throw Exception('Failed to fetch user data: $error');
final errorMsg = json.decode(response.body)['message'] ?? 'Unknown error';
logSafe("Failed to fetch user data: $errorMsg", level: LogLevel.warning);
throw Exception('Failed to fetch user data: $errorMsg');
} catch (e, 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 {
logSafe("Clearing tokens and redirecting to login due to unauthorized access.", level: LogLevel.warning);
await LocalStorage.removeToken('jwt_token');
await LocalStorage.removeToken('refresh_token');
await LocalStorage.setLoggedInUser(false);
Get.offAllNamed('/auth/login-option');
}
/// Converts raw permission data into list of `UserPermission`
/// Robust model parsing for permissions
static List<UserPermission> _parsePermissions(List<dynamic> permissions) {
logSafe("Parsing user permissions...");
return permissions
.map((id) => UserPermission.fromJson({'id': id}))
.map((perm) => UserPermission.fromJson({'id': perm}))
.toList();
}
/// Converts raw employee JSON into `EmployeeInfo`
static EmployeeInfo _parseEmployeeInfo(Map<String, dynamic> data) {
/// Robust model parsing for employee info
static EmployeeInfo _parseEmployeeInfo(Map<String, dynamic>? data) {
logSafe("Parsing employee info...");
if (data == null) throw Exception("Employee data missing");
return EmployeeInfo.fromJson(data);
}
/// Converts raw projects JSON into list of `ProjectInfo`
static List<ProjectInfo> _parseProjectsInfo(List<dynamic> projects) {
/// Robust model parsing for projects list
static List<ProjectInfo> _parseProjectsInfo(List<dynamic>? projects) {
logSafe("Parsing projects info...");
if (projects == null) return [];
return projects.map((proj) => ProjectInfo.fromJson(proj)).toList();
}
}

View File

@ -22,6 +22,17 @@ class LocalStorage {
static const String _isMpinKey = "isMpin";
static const String _fcmTokenKey = "fcm_token";
static const String _menuStorageKey = "dynamic_menus";
// In LocalStorage
static const String _recentTenantKey = "recent_tenant_id";
static Future<bool> setRecentTenantId(String tenantId) =>
preferences.setString(_recentTenantKey, tenantId);
static String? getRecentTenantId() =>
_initialized ? preferences.getString(_recentTenantKey) : null;
static Future<bool> removeRecentTenantId() =>
preferences.remove(_recentTenantKey);
static SharedPreferences? _preferencesInstance;
static bool _initialized = false;
@ -76,7 +87,8 @@ class LocalStorage {
static Future<bool> removeMenus() => preferences.remove(_menuStorageKey);
// ================== 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();
return preferences.setString(_userPermissionsKey, jsonEncode(jsonList));
}
@ -94,8 +106,8 @@ class LocalStorage {
preferences.remove(_userPermissionsKey);
// ================== Employee Info ==================
static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) =>
preferences.setString(_employeeInfoKey, jsonEncode(employeeInfo.toJson()));
static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) => preferences
.setString(_employeeInfoKey, jsonEncode(employeeInfo.toJson()));
static EmployeeInfo? getEmployeeInfo() {
if (!_initialized) return null;
@ -135,6 +147,7 @@ class LocalStorage {
await removeMpinToken();
await removeIsMpin();
await removeMenus();
await removeRecentTenantId();
await preferences.remove("mpin_verified");
await preferences.remove(_languageKey);
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,
cardRadius: AppStyle.cardRadius.medium,
buttonRadius: AppStyle.buttonRadius.medium,
defaultBreadCrumbItem: MyBreadcrumbItem(name: 'Marco', route: '/client/home'),
defaultBreadCrumbItem: MyBreadcrumbItem(name: 'Marco', route: '/client/dashboard'),
));
bool isMobile = true;
try {

View File

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

View File

@ -4,7 +4,6 @@ import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
class AttendanceDashboardChart extends StatelessWidget {
AttendanceDashboardChart({Key? key}) : super(key: key);
@ -46,13 +45,9 @@ class AttendanceDashboardChart extends StatelessWidget {
Color(0xFF64B5F6), // Blue 300 (repeat)
];
static final Map<String, Color> _roleColorMap = {};
Color _getRoleColor(String role) {
return _roleColorMap.putIfAbsent(
role,
() => _flatColors[_roleColorMap.length % _flatColors.length],
);
final index = role.hashCode.abs() % _flatColors.length;
return _flatColors[index];
}
@override
@ -62,12 +57,8 @@ class AttendanceDashboardChart extends StatelessWidget {
return Obx(() {
final isChartView = _controller.attendanceIsChartView.value;
final selectedRange = _controller.attendanceSelectedRange.value;
final isLoading = _controller.isAttendanceLoading.value;
final filteredData = _getFilteredData();
if (isLoading) {
return SkeletonLoaders.buildLoadingSkeleton();
}
return Container(
decoration: _containerDecoration,
@ -106,7 +97,7 @@ class AttendanceDashboardChart extends StatelessWidget {
BoxDecoration get _containerDecoration => BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.05),
@ -164,7 +155,7 @@ class _Header extends StatelessWidget {
),
),
ToggleButtons(
borderRadius: BorderRadius.circular(6),
borderRadius: BorderRadius.circular(5),
borderColor: Colors.grey,
fillColor: Colors.blueAccent.withOpacity(0.15),
selectedBorderColor: Colors.blueAccent,
@ -208,7 +199,7 @@ class _Header extends StatelessWidget {
: FontWeight.normal,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
borderRadius: BorderRadius.circular(5),
side: BorderSide(
color: selectedRange == label
? Colors.blueAccent
@ -262,7 +253,7 @@ class _AttendanceChart extends StatelessWidget {
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('d MMMM');
final dateFormat = DateFormat('d MMM');
final uniqueDates = data
.map((e) => DateTime.parse(e['date'] as String))
.toSet()
@ -281,10 +272,6 @@ class _AttendanceChart extends StatelessWidget {
if (allZero) {
return Container(
height: 600,
decoration: BoxDecoration(
color: Colors.blueGrey.shade50,
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Text(
'No attendance data for the selected range.',
@ -310,8 +297,7 @@ class _AttendanceChart extends StatelessWidget {
height: 600,
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.blueGrey.shade50,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(5),
),
child: SfCartesianChart(
tooltipBehavior: TooltipBehavior(enable: true, shared: true),
@ -325,7 +311,7 @@ class _AttendanceChart extends StatelessWidget {
return {'date': date, 'present': formattedMap[key] ?? 0};
})
.where((d) => (d['present'] ?? 0) > 0)
.toList(); // remove 0 bars
.toList();
return StackedColumnSeries<Map<String, dynamic>, String>(
dataSource: seriesData,
@ -366,7 +352,7 @@ class _AttendanceTable extends StatelessWidget {
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('d MMMM');
final dateFormat = DateFormat('d MMM');
final uniqueDates = data
.map((e) => DateTime.parse(e['date'] as String))
.toSet()
@ -385,10 +371,6 @@ class _AttendanceTable extends StatelessWidget {
if (allZero) {
return Container(
height: 300,
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text(
'No attendance data for the selected range.',
@ -409,39 +391,50 @@ class _AttendanceTable extends StatelessWidget {
height: 300,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(5),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columnSpacing: screenWidth < 600 ? 20 : 36,
headingRowHeight: 44,
headingRowColor:
MaterialStateProperty.all(Colors.blueAccent.withOpacity(0.08)),
headingTextStyle: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.black87),
columns: [
const DataColumn(label: Text('Role')),
...filteredDates.map((d) => DataColumn(label: Text(d))),
],
rows: filteredRoles.map((role) {
return DataRow(
cells: [
DataCell(_RolePill(role: role, color: getRoleColor(role))),
...filteredDates.map((date) {
final key = '${role}_$date';
return DataCell(
Text(
NumberFormat.decimalPattern()
.format(formattedMap[key] ?? 0),
style: const TextStyle(fontSize: 13),
),
child: Scrollbar(
thumbVisibility: true,
trackVisibility: true,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints:
BoxConstraints(minWidth: MediaQuery.of(context).size.width),
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: DataTable(
columnSpacing: 20,
headingRowHeight: 44,
headingRowColor: MaterialStateProperty.all(
Colors.blueAccent.withOpacity(0.08)),
headingTextStyle: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.black87),
columns: [
const DataColumn(label: Text('Role')),
...filteredDates.map((d) => DataColumn(label: Text(d))),
],
rows: filteredRoles.map((role) {
return DataRow(
cells: [
DataCell(
_RolePill(role: role, color: getRoleColor(role))),
...filteredDates.map((date) {
final key = '${role}_$date';
return DataCell(
Text(
NumberFormat.decimalPattern()
.format(formattedMap[key] ?? 0),
style: const TextStyle(fontSize: 13),
),
);
}),
],
);
}),
],
);
}).toList(),
}).toList(),
),
),
),
),
),
);
@ -461,7 +454,7 @@ class _RolePill extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(6),
borderRadius: BorderRadius.circular(5),
),
child: MyText.labelSmall(role, fontWeight: 500),
);

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class ConfirmDialog extends StatelessWidget {
final String title;
@ -115,7 +115,11 @@ class _ContentView extends StatelessWidget {
Navigator.pop(context, true); // close on success
} catch (e) {
// 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 {
loading.value = false;
}

View File

@ -38,7 +38,7 @@ void showAppSnackbar({
snackPosition: SnackPosition.BOTTOM,
margin: const EdgeInsets.all(16),
borderRadius: 8,
duration: const Duration(seconds: 3),
duration: const Duration(seconds: 5),
icon: Icon(
iconData,
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';
class AttendanceLogViewModel {
final DateTime? activityTime;
final String? imageUrl;
final String? comment;
final String? thumbPreSignedUrl;
final String? preSignedUrl;
final String? longitude;
final String? latitude;
final int? activity;
class Employee {
final String id;
final String firstName;
final String lastName;
final String? photo;
final String jobRoleId;
final String jobRoleName;
AttendanceLogViewModel({
this.activityTime,
this.imageUrl,
this.comment,
this.thumbPreSignedUrl,
this.preSignedUrl,
this.longitude,
this.latitude,
required this.activity,
Employee({
required this.id,
required this.firstName,
required this.lastName,
this.photo,
required this.jobRoleId,
required this.jobRoleName,
});
factory AttendanceLogViewModel.fromJson(Map<String, dynamic> json) {
return AttendanceLogViewModel(
activityTime: json['activityTime'] != null
? DateTime.tryParse(json['activityTime'])
: null,
imageUrl: json['imageUrl']?.toString(),
comment: json['comment']?.toString(),
thumbPreSignedUrl: json['thumbPreSignedUrl']?.toString(),
preSignedUrl: json['preSignedUrl']?.toString(),
longitude: json['longitude']?.toString(),
latitude: json['latitude']?.toString(),
activity: json['activity'] ?? 0,
factory Employee.fromJson(Map<String, dynamic> json) {
return Employee(
id: json['id'],
firstName: json['firstName'] ?? '',
lastName: json['lastName'] ?? '',
photo: json['photo']?.toString(),
jobRoleId: json['jobRoleId'] ?? '',
jobRoleName: json['jobRoleName'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'activityTime': activityTime?.toIso8601String(),
'imageUrl': imageUrl,
'id': id,
'firstName': firstName,
'lastName': lastName,
'photo': photo,
'jobRoleId': jobRoleId,
'jobRoleName': jobRoleName,
};
}
}
class AttendanceLogViewModel {
final String id;
final String? comment;
final Employee employee;
final DateTime? activityTime;
final int activity;
final String? photo;
final String? thumbPreSignedUrl;
final String? preSignedUrl;
final String? longitude;
final String? latitude;
final DateTime? updatedOn;
final Employee? updatedByEmployee;
final String? documentId;
AttendanceLogViewModel({
required this.id,
this.comment,
required this.employee,
this.activityTime,
required this.activity,
this.photo,
this.thumbPreSignedUrl,
this.preSignedUrl,
this.longitude,
this.latitude,
this.updatedOn,
this.updatedByEmployee,
this.documentId,
});
factory AttendanceLogViewModel.fromJson(Map<String, dynamic> json) {
return AttendanceLogViewModel(
id: json['id'],
comment: json['comment']?.toString(),
employee: Employee.fromJson(json['employee']),
activityTime: json['activityTime'] != null ? DateTime.tryParse(json['activityTime']) : null,
activity: json['activity'] ?? 0,
photo: json['photo']?.toString(),
thumbPreSignedUrl: json['thumbPreSignedUrl']?.toString(),
preSignedUrl: json['preSignedUrl']?.toString(),
longitude: json['longitude']?.toString(),
latitude: json['latitude']?.toString(),
updatedOn: json['updatedOn'] != null ? DateTime.tryParse(json['updatedOn']) : null,
updatedByEmployee: json['updatedByEmployee'] != null ? Employee.fromJson(json['updatedByEmployee']) : null,
documentId: json['documentId']?.toString(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'comment': comment,
'employee': employee.toJson(),
'activityTime': activityTime?.toIso8601String(),
'activity': activity,
'photo': photo,
'thumbPreSignedUrl': thumbPreSignedUrl,
'preSignedUrl': preSignedUrl,
'longitude': longitude,
'latitude': latitude,
'activity': activity,
'updatedOn': updatedOn?.toIso8601String(),
'updatedByEmployee': updatedByEmployee?.toJson(),
'documentId': documentId,
};
}
String? get formattedDate => activityTime != null
? DateFormat('yyyy-MM-dd').format(activityTime!)
: null;
String? get formattedDate =>
activityTime != null ? DateFormat('yyyy-MM-dd').format(activityTime!) : null;
String? get formattedTime =>
activityTime != null ? DateFormat('hh:mm a').format(activityTime!) : null;

View File

@ -108,8 +108,10 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
break;
case 1:
final isOldCheckIn = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2);
final isOldCheckOut = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2);
final isOldCheckIn =
AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2);
final isOldCheckOut =
AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2);
if (widget.employee.checkOut == null && isOldCheckIn) {
action = 2;
@ -167,7 +169,9 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
String? markTime;
if (actionText == ButtonActions.requestRegularize) {
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) {
markTime = DateFormat("hh:mm a").format(selectedTime);
}
@ -193,7 +197,7 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
controller.uploadingStates[uniqueLogKey]?.value = false;
if (success) {
await controller.fetchEmployeesByProject(selectedProjectId);
await controller.fetchTodaysAttendance(selectedProjectId);
await controller.fetchAttendanceLogs(selectedProjectId);
await controller.fetchRegularizationLogs(selectedProjectId);
await controller.fetchProjectData(selectedProjectId);
@ -205,13 +209,17 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
Widget build(BuildContext context) {
return Obx(() {
final controller = widget.attendanceController;
final isUploading = controller.uploadingStates[uniqueLogKey]?.value ?? false;
final isUploading =
controller.uploadingStates[uniqueLogKey]?.value ?? false;
final emp = widget.employee;
final isYesterday = AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut);
final isTodayApproved = AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn);
final isYesterday =
AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut);
final isTodayApproved =
AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn);
final isApprovedButNotToday =
AttendanceButtonHelper.isApprovedButNotToday(emp.activity, isTodayApproved);
AttendanceButtonHelper.isApprovedButNotToday(
emp.activity, isTodayApproved);
final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled(
isUploading: isUploading,
@ -272,12 +280,12 @@ class AttendanceActionButtonUI extends StatelessWidget {
textStyle: const TextStyle(fontSize: 12),
),
child: isUploading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
? Container(
width: 60,
height: 14,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
)
: Row(
@ -288,7 +296,8 @@ class AttendanceActionButtonUI extends StatelessWidget {
if (buttonText.toLowerCase() == 'rejected')
const Icon(Icons.close, size: 16, color: Colors.red),
if (buttonText.toLowerCase() == 'requested')
const Icon(Icons.hourglass_top, size: 16, color: Colors.orange),
const Icon(Icons.hourglass_top,
size: 16, color: Colors.orange),
if (['approved', 'rejected', 'requested']
.contains(buttonText.toLowerCase()))
const SizedBox(width: 4),
@ -342,7 +351,8 @@ Future<String?> _showCommentBottomSheet(
}
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom),
child: BaseBottomSheet(
title: sheetTitle, // 👈 now showing full sentence as title
onCancel: () => Navigator.of(context).pop(),
@ -375,6 +385,5 @@ Future<String?> _showCommentBottomSheet(
);
}
String capitalizeFirstLetter(String text) =>
text.isEmpty ? text : text[0].toUpperCase() + text.substring(1);

View File

@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/attendance/attendance_screen_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
class AttendanceFilterBottomSheet extends StatefulWidget {
final AttendanceController controller;
@ -36,14 +37,79 @@ class _AttendanceFilterBottomSheetState
String getLabelText() {
final startDate = widget.controller.startDateAttendance;
final endDate = widget.controller.endDateAttendance;
if (startDate != null && endDate != null) {
final start = DateFormat('dd/MM/yyyy').format(startDate);
final end = DateFormat('dd/MM/yyyy').format(endDate);
final start = DateTimeUtils.formatDate(startDate, 'dd MMM yyyy');
final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy');
return "$start - $end";
}
return "Date Range";
}
Widget _popupSelector({
required String currentValue,
required List<String> items,
required ValueChanged<String> onSelected,
}) {
return PopupMenuButton<String>(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: onSelected,
itemBuilder: (context) => items
.map((e) => PopupMenuItem<String>(
value: e,
child: MyText(e),
))
.toList(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: MyText(
currentValue,
style: const TextStyle(color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
);
}
Widget _buildOrganizationSelector(BuildContext context) {
final orgNames = [
"All Organizations",
...widget.controller.organizations.map((e) => e.name)
];
return _popupSelector(
currentValue:
widget.controller.selectedOrganization?.name ?? "All Organizations",
items: orgNames,
onSelected: (name) {
if (name == "All Organizations") {
setState(() {
widget.controller.selectedOrganization = null;
});
} else {
final selectedOrg = widget.controller.organizations
.firstWhere((org) => org.name == name);
setState(() {
widget.controller.selectedOrganization = selectedOrg;
});
}
},
);
}
List<Widget> buildMainFilters() {
final hasRegularizationPermission = widget.permissionController
.hasPermission(Permissions.regularizeAttendance);
@ -61,7 +127,7 @@ class _AttendanceFilterBottomSheetState
final List<Widget> widgets = [
Padding(
padding: EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.only(bottom: 4),
child: Align(
alignment: Alignment.centerLeft,
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') {
widgets.addAll([
const Divider(),
Padding(
padding: EdgeInsets.only(top: 12, bottom: 4),
padding: const EdgeInsets.only(top: 12, bottom: 4),
child: Align(
alignment: Alignment.centerLeft,
child: MyText.titleSmall("Date Range", fontWeight: 600),
@ -99,7 +220,7 @@ class _AttendanceFilterBottomSheetState
context,
widget.controller,
);
setState(() {}); // rebuild UI after date range is updated
setState(() {});
},
child: Ink(
decoration: BoxDecoration(
@ -136,9 +257,11 @@ class _AttendanceFilterBottomSheetState
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: BaseBottomSheet(
title: "Attendance Filter",
submitText: "Apply",
onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context, {
'selectedTab': tempSelectedTab,
'selectedOrganization': widget.controller.selectedOrganization?.id,
}),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -1,18 +1,25 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/attendance_actions.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 attendanceController;
const AttendanceLogViewButton({
Key? key,
required this.employee,
required this.attendanceController,
}) : super(key: key);
@override
State<AttendanceLogViewButton> createState() =>
_AttendanceLogViewButtonState();
}
class _AttendanceLogViewButtonState extends State<AttendanceLogViewButton> {
Future<void> _openGoogleMaps(
BuildContext context, double lat, double lon) async {
final url = 'https://www.google.com/maps/search/?api=1&query=$lat,$lon';
@ -49,7 +56,8 @@ class AttendanceLogViewButton extends StatelessWidget {
}
void _showLogsBottomSheet(BuildContext context) async {
await attendanceController.fetchLogsView(employee.id.toString());
await widget.attendanceController
.fetchLogsView(widget.employee.id.toString());
showModalBottomSheet(
context: context,
@ -58,157 +66,238 @@ class AttendanceLogViewButton extends StatelessWidget {
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
backgroundColor: Colors.transparent,
builder: (context) => BaseBottomSheet(
title: "Attendance Log",
onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context),
showButtons: false,
child: attendanceController.attendenceLogsView.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: Column(
children: const [
Icon(Icons.info_outline, size: 40, color: Colors.grey),
SizedBox(height: 8),
Text("No attendance logs available."),
],
),
)
: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: attendanceController.attendenceLogsView.length,
separatorBuilder: (_, __) => const SizedBox(height: 16),
itemBuilder: (_, index) {
final log = attendanceController.attendenceLogsView[index];
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6,
offset: const Offset(0, 2),
)
],
),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
builder: (context) {
Map<int, bool> expandedDescription = {};
return BaseBottomSheet(
title: "Attendance Log",
onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context),
showButtons: false,
child: widget.attendanceController.attendenceLogsView.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: Column(
children: [
Icon(Icons.info_outline, size: 40, color: Colors.grey),
SizedBox(height: 8),
MyText.bodySmall("No attendance logs available."),
],
),
)
: StatefulBuilder(
builder: (context, setStateSB) {
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount:
widget.attendanceController.attendenceLogsView.length,
separatorBuilder: (_, __) => const SizedBox(height: 16),
itemBuilder: (_, index) {
final log = widget
.attendanceController.attendenceLogsView[index];
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6,
offset: const Offset(0, 2),
)
],
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: Icon + Date + Time
Row(
children: [
Row(
children: [
_getLogIcon(log),
const SizedBox(width: 10),
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
MyText.bodyLarge(
log.formattedDate ?? '-',
fontWeight: 600,
),
MyText.bodySmall(
"Time: ${log.formattedTime ?? '-'}",
color: Colors.grey[700],
),
],
),
],
_getLogIcon(log),
const SizedBox(width: 12),
MyText.bodyLarge(
(log.formattedDate != null &&
log.formattedDate!.isNotEmpty)
? DateTimeUtils.convertUtcToLocal(
log.formattedDate!,
format: 'd MMM yyyy',
)
: '-',
fontWeight: 600,
),
const SizedBox(height: 12),
Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
if (log.latitude != null &&
log.longitude != null)
GestureDetector(
onTap: () {
final lat = double.tryParse(
log.latitude.toString()) ??
0.0;
final lon = double.tryParse(
log.longitude.toString()) ??
0.0;
if (lat >= -90 &&
lat <= 90 &&
lon >= -180 &&
lon <= 180) {
_openGoogleMaps(
context, lat, lon);
} else {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Invalid location coordinates')),
);
}
},
child: const Padding(
padding:
EdgeInsets.only(right: 8.0),
child: Icon(Icons.location_on,
size: 18, color: Colors.blue),
),
),
Expanded(
child: MyText.bodyMedium(
log.comment?.isNotEmpty == true
? log.comment
: "No description provided",
fontWeight: 500,
),
),
],
const SizedBox(width: 12),
MyText.bodySmall(
log.formattedTime != null
? "Time: ${log.formattedTime}"
: "",
color: Colors.grey[700],
),
],
),
),
const SizedBox(width: 16),
if (log.thumbPreSignedUrl != null)
GestureDetector(
onTap: () {
if (log.preSignedUrl != null) {
_showImageDialog(
context, log.preSignedUrl!);
}
},
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
log.thumbPreSignedUrl!,
height: 60,
width: 60,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(Icons.broken_image,
size: 20, color: Colors.grey);
},
const SizedBox(height: 12),
const Divider(height: 1, color: Colors.grey),
// Middle Row: Image + Text (Done by, Description & Location)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image Column
if (log.thumbPreSignedUrl != null)
GestureDetector(
onTap: () {
if (log.preSignedUrl != null) {
_showImageDialog(
context, log.preSignedUrl!);
}
},
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
log.thumbPreSignedUrl!,
height: 60,
width: 60,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const Icon(Icons.broken_image,
size: 40, color: Colors.grey),
),
),
),
if (log.thumbPreSignedUrl != null)
const SizedBox(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(
onPressed: () => _showLogsBottomSheet(context),
style: ElevatedButton.styleFrom(
backgroundColor: AttendanceActionColors.colors[ButtonActions.checkIn],
backgroundColor: Colors.indigo,
textStyle: const TextStyle(fontSize: 12),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
child: const FittedBox(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
child: MyText.bodySmall(
"View",
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 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);
}

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

View File

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

View File

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

View File

@ -1,82 +1,289 @@
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/permission_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class DailyProgressReportFilter extends StatelessWidget {
class DailyTaskFilterBottomSheet extends StatelessWidget {
final DailyTaskController controller;
final PermissionController permissionController;
const DailyProgressReportFilter({
super.key,
required this.controller,
required this.permissionController,
});
String getLabelText() {
final startDate = controller.startDateTask;
final endDate = controller.endDateTask;
if (startDate != null && endDate != null) {
final start = DateFormat('dd MM yyyy').format(startDate);
final end = DateFormat('dd MM yyyy').format(endDate);
return "$start - $end";
}
return "Select Date Range";
}
const DailyTaskFilterBottomSheet({super.key, required this.controller});
@override
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(
title: "Filter Tasks",
onCancel: () => Navigator.pop(context),
submitText: "Apply",
showButtons: hasFilters,
onCancel: () => Get.back(),
onSubmit: () {
Navigator.pop(context, {
'startDate': controller.startDateTask,
'endDate': controller.endDateTask,
});
if (controller.selectedProjectId != null) {
controller.fetchTaskData(
controller.selectedProjectId!,
);
}
Get.back();
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall("Select Date Range", fontWeight: 600),
const SizedBox(height: 8),
InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => controller.selectDateRangeForTaskData(
context,
controller,
),
child: Ink(
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
child: SingleChildScrollView(
child: hasFilters
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.date_range, color: Colors.blue.shade600),
const SizedBox(width: 12),
Expanded(
child: Text(
getLabelText(),
style: const TextStyle(
fontSize: 16,
color: Colors.black87,
fontWeight: FontWeight.w500,
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
controller.clearTaskFilters();
},
child: MyText(
"Reset Filter",
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,
this.reportedDate,
required this.id,
required this.workItem,
this.workItem,
required this.workItemId,
required this.plannedTask,
required this.completedTask,
required this.assignedBy,
this.approvedBy,
required this.teamMembers,
required this.comments,
required this.reportedPreSignedUrls,
this.teamMembers = const [],
this.comments = const [],
this.reportedPreSignedUrls = const [],
});
factory TaskModel.fromJson(Map<String, dynamic> json) {
return TaskModel(
assignmentDate: DateTime.parse(json['assignmentDate']),
reportedDate: json['reportedDate'] != null
? DateTime.tryParse(json['reportedDate'])
: null,
id: json['id'],
workItem:
json['workItem'] != null ? WorkItem.fromJson(json['workItem']) : null,
workItemId: json['workItemId'],
plannedTask: (json['plannedTask'] as num).toDouble(),
completedTask: (json['completedTask'] as num).toDouble(),
assignedBy: AssignedBy.fromJson(json['assignedBy']),
approvedBy: json['approvedBy'] != null
? AssignedBy.fromJson(json['approvedBy'])
: null,
teamMembers: (json['teamMembers'] as List)
.map((e) => TeamMember.fromJson(e))
.toList(),
comments:
(json['comments'] as List).map((e) => Comment.fromJson(e)).toList(),
assignmentDate: DateTime.parse(json['assignmentDate'] ?? DateTime.now().toIso8601String()),
reportedDate: json['reportedDate'] != null ? DateTime.tryParse(json['reportedDate']) : null,
id: json['id']?.toString() ?? '',
workItem: json['workItem'] != null ? WorkItem.fromJson(json['workItem']) : null,
workItemId: json['workItemId']?.toString() ?? '',
plannedTask: (json['plannedTask'] as num?)?.toDouble() ?? 0,
completedTask: (json['completedTask'] as num?)?.toDouble() ?? 0,
assignedBy: AssignedBy.fromJson(json['assignedBy'] ?? {}),
approvedBy: json['approvedBy'] != null ? AssignedBy.fromJson(json['approvedBy']) : null,
teamMembers: (json['teamMembers'] as List<dynamic>?)
?.map((e) => TeamMember.fromJson(e))
.toList() ??
[],
comments: (json['comments'] as List<dynamic>?)
?.map((e) => Comment.fromJson(e))
.toList() ??
[],
reportedPreSignedUrls: (json['reportedPreSignedUrls'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
@ -79,8 +77,7 @@ class WorkItem {
activityMaster: json['activityMaster'] != null
? ActivityMaster.fromJson(json['activityMaster'])
: null,
workArea:
json['workArea'] != null ? WorkArea.fromJson(json['workArea']) : null,
workArea: json['workArea'] != null ? WorkArea.fromJson(json['workArea']) : null,
plannedWork: (json['plannedWork'] as num?)?.toDouble(),
completedWork: (json['completedWork'] as num?)?.toDouble(),
preSignedUrls: (json['preSignedUrls'] as List<dynamic>?)
@ -92,7 +89,7 @@ class WorkItem {
}
class ActivityMaster {
final String? id; // Added
final String? id;
final String activityName;
ActivityMaster({
@ -103,13 +100,13 @@ class ActivityMaster {
factory ActivityMaster.fromJson(Map<String, dynamic> json) {
return ActivityMaster(
id: json['id']?.toString(),
activityName: json['activityName'] ?? '',
activityName: json['activityName']?.toString() ?? '',
);
}
}
class WorkArea {
final String? id; // Added
final String? id;
final String areaName;
final Floor? floor;
@ -122,7 +119,7 @@ class WorkArea {
factory WorkArea.fromJson(Map<String, dynamic> json) {
return WorkArea(
id: json['id']?.toString(),
areaName: json['areaName'] ?? '',
areaName: json['areaName']?.toString() ?? '',
floor: json['floor'] != null ? Floor.fromJson(json['floor']) : null,
);
}
@ -136,9 +133,8 @@ class Floor {
factory Floor.fromJson(Map<String, dynamic> json) {
return Floor(
floorName: json['floorName'] ?? '',
building:
json['building'] != null ? Building.fromJson(json['building']) : null,
floorName: json['floorName']?.toString() ?? '',
building: json['building'] != null ? Building.fromJson(json['building']) : null,
);
}
}
@ -149,7 +145,7 @@ class Building {
Building({required this.name});
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) {
return AssignedBy(
id: json['id']?.toString() ?? '',
firstName: json['firstName'] ?? '',
lastName: json['lastName'],
firstName: json['firstName']?.toString() ?? '',
lastName: json['lastName']?.toString(),
);
}
}
@ -203,7 +199,7 @@ class Comment {
required this.comment,
required this.commentedBy,
required this.timestamp,
required this.preSignedUrls,
this.preSignedUrls = const [],
});
factory Comment.fromJson(Map<String, dynamic> json) {
@ -212,7 +208,9 @@ class Comment {
commentedBy: json['employee'] != null
? TeamMember.fromJson(json['employee'])
: 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>?)
?.map((e) => e.toString())
.toList() ??

View File

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

View File

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

View File

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

View File

@ -24,6 +24,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
final nameCtrl = TextEditingController();
final orgCtrl = TextEditingController();
final designationCtrl = TextEditingController();
final addrCtrl = TextEditingController();
final descCtrl = TextEditingController();
final tagCtrl = TextEditingController();
@ -49,6 +50,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
if (c != null) {
nameCtrl.text = c.name;
orgCtrl.text = c.organization;
designationCtrl.text = c.designation ?? '';
addrCtrl.text = c.address;
descCtrl.text = c.description;
@ -72,12 +74,20 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
ever(controller.isInitialized, (bool 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 bucketId = c.bucketIds.firstOrNull;
final category = c.contactCategory?.name;
if (category != null) controller.selectedCategory.value = category;
if (projectIds != null) {
controller.selectedProjects.assignAll(
projectIds
@ -88,16 +98,12 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
.toList(),
);
}
if (bucketId != null) {
final name = controller.bucketsMap.entries
.firstWhereOrNull((e) => e.value == bucketId)
?.key;
if (name != null) controller.selectedBucket.value = name;
}
final category = c.contactCategory?.name;
if (category != null) controller.selectedCategory.value = category;
}
});
} else {
showAdvanced.value = false; // Optional
emailCtrls.add(TextEditingController());
emailLabels.add('Office'.obs);
phoneCtrls.add(TextEditingController());
@ -109,6 +115,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
void dispose() {
nameCtrl.dispose();
orgCtrl.dispose();
designationCtrl.dispose();
addrCtrl.dispose();
descCtrl.dispose();
tagCtrl.dispose();
@ -118,6 +125,20 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
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(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
@ -145,7 +166,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
_labelWithStar(label, required: required),
MySpacing.height(8),
TextFormField(
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() {
bool valid = formKey.currentState?.validate() ?? false;
if (controller.selectedBucket.value.isEmpty) {
if (controller.selectedBuckets.isEmpty) {
bucketError.value = "Bucket is required";
valid = false;
} else {
@ -386,6 +526,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
phones: phones,
address: addrCtrl.text.trim(),
description: descCtrl.text.trim(),
designation: designationCtrl.text.trim(),
);
}
@ -412,29 +553,14 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
MySpacing.height(16),
_textField("Organization", orgCtrl, required: true),
MySpacing.height(16),
MyText.labelMedium("Select Bucket"),
_labelWithStar("Buckets", required: true),
MySpacing.height(8),
Stack(
children: [
_popupSelector(controller.selectedBucket, controller.buckets,
"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)),
)),
),
_bucketMultiSelectField(),
],
),
MySpacing.height(24),
MySpacing.height(12),
Obx(() => GestureDetector(
onTap: () => showAdvanced.toggle(),
child: Row(
@ -477,19 +603,63 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
icon: const Icon(Icons.add),
label: const Text("Add Phone"),
),
MySpacing.height(16),
MyText.labelMedium("Category"),
MySpacing.height(8),
_popupSelector(controller.selectedCategory,
controller.categories, "Select Category"),
MySpacing.height(16),
MyText.labelMedium("Tags"),
MySpacing.height(8),
_tagInput(),
MySpacing.height(16),
_textField("Address", addrCtrl),
MySpacing.height(16),
_textField("Description", descCtrl),
Obx(() => showAdvanced.value
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Move Designation field here
_textField("Designation", designationCtrl),
MySpacing.height(16),
_dynamicList(
emailCtrls,
emailLabels,
"Email",
["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()),
@ -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 List<String>? projectIds;
final String name;
final String? designation;
final List<ContactPhone> contactPhones;
final List<ContactEmail> contactEmails;
final ContactCategory? contactCategory;
@ -15,6 +16,7 @@ class ContactModel {
required this.id,
required this.projectIds,
required this.name,
this.designation,
required this.contactPhones,
required this.contactEmails,
required this.contactCategory,
@ -30,6 +32,7 @@ class ContactModel {
id: json['id'],
projectIds: (json['projectIds'] as List?)?.map((e) => e as String).toList(),
name: json['name'],
designation: json['designation'],
contactPhones: (json['contactPhones'] as List)
.map((e) => ContactPhone.fromJson(e))
.toList(),
@ -48,6 +51,7 @@ class ContactModel {
}
}
class ContactPhone {
final String id;
final String label;

View File

@ -69,6 +69,15 @@ class DirectoryComment {
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({
String? id,

View File

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

View File

@ -194,8 +194,11 @@ class _DocumentUploadBottomSheetState extends State<DocumentUploadBottomSheet> {
@override
Widget build(BuildContext context) {
final sheetTitle = widget.isEmployee
? "Upload Employee Document"
: "Upload Project Document";
return BaseBottomSheet(
title: "Upload Document",
title: sheetTitle,
onCancel: () => Navigator.pop(context),
onSubmit: _handleSubmit,
child: Form(
@ -206,46 +209,6 @@ class _DocumentUploadBottomSheetState extends State<DocumentUploadBottomSheet> {
children: [
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
Obx(() {
if (controller.isLoading.value &&
@ -287,7 +250,47 @@ class _DocumentUploadBottomSheetState extends State<DocumentUploadBottomSheet> {
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
AttachmentSectionSingle(
@ -393,6 +396,7 @@ class _DocumentUploadBottomSheetState extends State<DocumentUploadBottomSheet> {
validator: (value) =>
value == null || value.trim().isEmpty ? "Required" : null,
isRequired: true,
maxLines: 3,
),
],
),
@ -564,6 +568,7 @@ class LabeledInput extends StatelessWidget {
final TextEditingController controller;
final String? Function(String?) validator;
final bool isRequired;
final int maxLines;
const LabeledInput({
Key? key,
@ -572,6 +577,7 @@ class LabeledInput extends StatelessWidget {
required this.controller,
required this.validator,
this.isRequired = false,
this.maxLines = 1,
}) : super(key: key);
@override
@ -594,6 +600,7 @@ class LabeledInput extends StatelessWidget {
controller: controller,
validator: validator,
decoration: _inputDecoration(context, hint),
maxLines: maxLines,
),
],
);

View File

@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/document/user_document_controller.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_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/model/document/document_filter_model.dart';
import 'dart:convert';
class UserDocumentFilterBottomSheet extends StatelessWidget {
final String entityId;
@ -32,19 +34,26 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
return BaseBottomSheet(
title: 'Filter Documents',
submitText: 'Apply',
showButtons: hasFilters,
onCancel: () => Get.back(),
onSubmit: () {
final combinedFilter = {
'uploadedBy': docController.selectedUploadedBy.value,
'category': docController.selectedCategory.value,
'type': docController.selectedType.value,
'tag': docController.selectedTag.value,
'uploadedByIds': docController.selectedUploadedBy.toList(),
'documentCategoryIds': docController.selectedCategory.toList(),
'documentTypeIds': docController.selectedType.toList(),
'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(
entityTypeId: entityTypeId,
entityId: entityId,
filter: combinedFilter.toString(),
filter: jsonEncode(combinedFilter),
reset: true,
);
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),
_buildDynamicField(
_multiSelectField(
label: "Uploaded By",
items: filterData.uploadedBy,
fallback: "Select Uploaded By",
selectedValue: docController.selectedUploadedBy,
fallback: "Choose Uploaded By",
selectedValues: docController.selectedUploadedBy,
),
_buildDynamicField(
_multiSelectField(
label: "Category",
items: filterData.documentCategory,
fallback: "Select Category",
selectedValue: docController.selectedCategory,
fallback: "Choose Category",
selectedValues: docController.selectedCategory,
),
_buildDynamicField(
_multiSelectField(
label: "Type",
items: filterData.documentType,
fallback: "Select Type",
selectedValue: docController.selectedType,
fallback: "Choose Type",
selectedValues: docController.selectedType,
),
_buildDynamicField(
_multiSelectField(
label: "Tag",
items: filterData.documentTag,
fallback: "Select Tag",
selectedValue: docController.selectedTag,
fallback: "Choose Tag",
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(
child: Padding(
@ -110,70 +324,161 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
);
}
Widget? _buildDynamicField({
Widget _multiSelectField({
required String label,
required List<FilterItem> items,
required String fallback,
required RxString selectedValue,
required RxList<String> selectedValues,
}) {
if (items.isEmpty) return null;
if (items.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
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),
],
);
}
Widget _popupSelector(
List<FilterItem> items,
String fallback, {
required RxString selectedValue,
}) {
return Obx(() {
final currentValue = _getCurrentName(selectedValue.value, items, fallback);
return PopupMenuButton<String>(
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),
],
),
),
);
});
Widget _buildField(String label, Widget child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
MySpacing.height(8),
child,
MySpacing.height(8),
],
);
}
String _getCurrentName(String selectedId, List<FilterItem> list, String fallback) {
if (selectedId.isEmpty) return fallback;
final match = list.firstWhereOrNull((f) => f.id == selectedId);
return match?.name ?? fallback;
Widget _dateButton({required String label, required VoidCallback onTap}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: MySpacing.xy(16, 12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
children: [
const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
MySpacing.width(8),
Expanded(
child: MyText(
label,
style: MyTextStyle.bodyMedium(),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}

View File

@ -1,33 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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/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/widgets/my_snackbar.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class AddEmployeeBottomSheet extends StatefulWidget {
final Map<String, dynamic>? employeeData;
const AddEmployeeBottomSheet({super.key, this.employeeData});
@override
State<AddEmployeeBottomSheet> createState() => _AddEmployeeBottomSheetState();
}
class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
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
Widget build(BuildContext context) {
return GetBuilder<AddEmployeeController>(
init: _controller,
builder: (_) {
// Keep org field in sync with controller selection
_orgFieldController.text = _organizationController.currentSelection;
return BaseBottomSheet(
title: "Add Employee",
title: widget.employeeData != null ? 'Edit Employee' : 'Add Employee',
onCancel: () => Navigator.pop(context),
onSubmit: _handleSubmit,
child: Form(
@ -35,11 +137,11 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel("Personal Info"),
_sectionLabel('Personal Info'),
MySpacing.height(16),
_inputWithIcon(
label: "First Name",
hint: "e.g., John",
label: 'First Name',
hint: 'e.g., John',
icon: Icons.person,
controller:
_controller.basicValidator.getController('first_name')!,
@ -48,8 +150,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
),
MySpacing.height(16),
_inputWithIcon(
label: "Last Name",
hint: "e.g., Doe",
label: 'Last Name',
hint: 'e.g., Doe',
icon: Icons.person_outline,
controller:
_controller.basicValidator.getController('last_name')!,
@ -57,37 +159,101 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
_controller.basicValidator.getValidation('last_name'),
),
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),
_buildDatePickerField(
label: "Joining Date",
value: _controller.joiningDate != null
? DateFormat("dd MMM yyyy")
.format(_controller.joiningDate!)
: "",
hint: "Select Joining Date",
label: 'Joining Date',
controller: _joiningDateController,
hint: 'Select Joining Date',
onTap: () => _pickJoiningDate(context),
),
MySpacing.height(16),
_sectionLabel("Contact Details"),
_sectionLabel('Contact Details'),
MySpacing.height(16),
_buildPhoneInput(context),
MySpacing.height(24),
_sectionLabel("Other Details"),
_sectionLabel('Other Details'),
MySpacing.height(16),
_buildDropdownField(
label: "Gender",
value: _controller.selectedGender?.name.capitalizeFirst ?? '',
hint: "Select Gender",
label: 'Gender',
controller: _genderController,
hint: 'Select Gender',
onTap: () => _showGenderPopup(context),
),
MySpacing.height(16),
_buildDropdownField(
label: "Role",
value: _controller.roles.firstWhereOrNull((role) =>
role['id'] == _controller.selectedRoleId)?['name'] ??
"",
hint: "Select Role",
label: 'Role',
controller: _roleController,
hint: 'Select Role',
onTap: () => _showRolePopup(context),
),
],
@ -98,138 +264,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
);
}
// --- Common label with red star ---
Widget _requiredLabel(String text) {
return Row(
children: [
MyText.labelMedium(text),
const SizedBox(width: 4),
const Text("*", style: TextStyle(color: Colors.red)),
],
);
}
// UI Pieces
// --- 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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -239,124 +275,16 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
],
);
// --- Input field with icon ---
Widget _inputWithIcon({
required String label,
required String hint,
required IconData icon,
required TextEditingController controller,
required String? Function(String?)? validator,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
Widget _requiredLabel(String text) {
return Row(
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),
),
),
MyText.labelMedium(text),
const SizedBox(width: 4),
const Text('*', style: TextStyle(color: Colors.red)),
],
);
}
// --- 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) {
return InputDecoration(
hintText: hint,
@ -379,40 +307,322 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
);
}
// --- Gender popup ---
void _showGenderPopup(BuildContext context) async {
final selected = await showMenu<Gender>(
Widget _inputWithIcon({
required String label,
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,
position: _popupMenuPosition(context),
items: Gender.values.map((gender) {
return PopupMenuItem<Gender>(
value: gender,
child: Text(gender.name.capitalizeFirst!),
);
}).toList(),
initialDate: _controller.joiningDate ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (selected != null) {
_controller.onGenderSelected(selected);
if (picked != null) {
_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();
}
}
// --- Role popup ---
void _showRolePopup(BuildContext context) async {
final selected = await showMenu<String>(
context: context,
position: _popupMenuPosition(context),
items: _controller.roles.map((role) {
return PopupMenuItem<String>(
value: role['id'],
child: Text(role['name']),
);
}).toList(),
items: _controller.roles
.map(
(role) => PopupMenuItem<String>(
value: role['id'],
child: Text(role['name']),
),
)
.toList(),
);
if (selected != null) {
_controller.onRoleSelected(selected);
final roleName = _controller.roles
.firstWhereOrNull((r) => r['id'] == selected)?['name'] ??
'';
_roleController.text = roleName;
_controller.update();
}
}

View File

@ -12,15 +12,17 @@ class EmployeeDetailsModel {
final String phoneNumber;
final String? emergencyPhoneNumber;
final String? emergencyContactPerson;
final String? aadharNumber;
final bool isActive;
final String? panNumber;
final String? photo;
final String? applicationUserId;
final String jobRoleId;
final bool isRootUser;
final bool isSystem;
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({
required this.id,
required this.firstName,
@ -35,14 +37,17 @@ class EmployeeDetailsModel {
required this.phoneNumber,
this.emergencyPhoneNumber,
this.emergencyContactPerson,
this.aadharNumber,
required this.isActive,
this.panNumber,
this.photo,
this.applicationUserId,
required this.jobRoleId,
required this.isRootUser,
required this.isSystem,
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) {
@ -60,24 +65,20 @@ class EmployeeDetailsModel {
phoneNumber: json['phoneNumber'],
emergencyPhoneNumber: json['emergencyPhoneNumber'],
emergencyContactPerson: json['emergencyContactPerson'],
aadharNumber: json['aadharNumber'],
isActive: json['isActive'],
panNumber: json['panNumber'],
photo: json['photo'],
applicationUserId: json['applicationUserId'],
jobRoleId: json['jobRoleId'],
isRootUser: json['isRootUser'],
isSystem: json['isSystem'],
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() {
return {
'id': id,
@ -93,14 +94,24 @@ class EmployeeDetailsModel {
'phoneNumber': phoneNumber,
'emergencyPhoneNumber': emergencyPhoneNumber,
'emergencyContactPerson': emergencyContactPerson,
'aadharNumber': aadharNumber,
'isActive': isActive,
'panNumber': panNumber,
'photo': photo,
'applicationUserId': applicationUserId,
'jobRoleId': jobRoleId,
'isRootUser': isRootUser,
'isSystem': isSystem,
'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 {
final String id;
final String name;
final String projectAddress;
final String contactPerson;
final DateTime startDate;
final DateTime endDate;
final int teamSize;
final String projectStatusId;
final String? tenantId;
final String id;
final String name;
final String projectAddress;
final String contactPerson;
final DateTime? startDate;
final DateTime? endDate;
final int teamSize;
final String projectStatusId;
final String? tenantId;
GlobalProjectModel({
required this.id,
required this.name,
required this.projectAddress,
required this.contactPerson,
required this.startDate,
required this.endDate,
required this.teamSize,
required this.projectStatusId,
this.tenantId,
});
GlobalProjectModel({
required this.id,
required this.name,
required this.projectAddress,
required this.contactPerson,
this.startDate,
this.endDate,
required this.teamSize,
required this.projectStatusId,
this.tenantId,
});
factory GlobalProjectModel.fromJson(Map<String, dynamic> json) {
return GlobalProjectModel(
id: json['id'] ?? '',
name: json['name'] ?? '',
projectAddress: json['projectAddress'] ?? '',
contactPerson: json['contactPerson'] ?? '',
startDate: DateTime.parse(json['startDate']),
endDate: DateTime.parse(json['endDate']),
teamSize: json['teamSize'] ?? 0, // SAFER
projectStatusId: json['projectStatusId'] ?? '',
tenantId: json['tenantId'],
);
}
factory GlobalProjectModel.fromJson(Map<String, dynamic> json) {
return GlobalProjectModel(
id: json['id'] ?? '',
name: json['name'] ?? '',
projectAddress: json['projectAddress'] ?? '',
contactPerson: json['contactPerson'] ?? '',
startDate: _parseDate(json['startDate']),
endDate: _parseDate(json['endDate']),
teamSize: json['teamSize'] is int
? json['teamSize']
: 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,
};
}
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;
}
}
}

View File

@ -3,8 +3,8 @@ class ProjectModel {
final String name;
final String projectAddress;
final String contactPerson;
final DateTime startDate;
final DateTime endDate;
final DateTime? startDate;
final DateTime? endDate;
final int teamSize;
final double completedWork;
final double plannedWork;
@ -16,8 +16,8 @@ class ProjectModel {
required this.name,
required this.projectAddress,
required this.contactPerson,
required this.startDate,
required this.endDate,
this.startDate,
this.endDate,
required this.teamSize,
required this.completedWork,
required this.plannedWork,
@ -25,36 +25,30 @@ class ProjectModel {
this.tenantId,
});
// Factory method to create an instance of ProjectModel from a JSON object
factory ProjectModel.fromJson(Map<String, dynamic> json) {
return ProjectModel(
id: json['id'],
name: json['name'],
projectAddress: json['projectAddress'],
contactPerson: json['contactPerson'],
startDate: DateTime.parse(json['startDate']),
endDate: DateTime.parse(json['endDate']),
teamSize: json['teamSize'],
completedWork: json['completedWork'] != null
? (json['completedWork'] as num).toDouble()
: 0.0,
plannedWork: json['plannedWork'] != null
? (json['plannedWork'] as num).toDouble()
: 0.0,
projectStatusId: json['projectStatusId'],
tenantId: json['tenantId'],
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
projectAddress: json['projectAddress']?.toString() ?? '',
contactPerson: json['contactPerson']?.toString() ?? '',
startDate: _parseDate(json['startDate']),
endDate: _parseDate(json['endDate']),
teamSize: _parseInt(json['teamSize']),
completedWork: _parseDouble(json['completedWork']),
plannedWork: _parseDouble(json['plannedWork']),
projectStatusId: json['projectStatusId']?.toString() ?? '',
tenantId: json['tenantId']?.toString(),
);
}
// Method to convert the ProjectModel instance back to a JSON object
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'projectAddress': projectAddress,
'contactPerson': contactPerson,
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
'startDate': startDate?.toIso8601String(),
'endDate': endDate?.toIso8601String(),
'teamSize': teamSize,
'completedWork': completedWork,
'plannedWork': plannedWork,
@ -62,4 +56,30 @@ class ProjectModel {
'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:get/get.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/login_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/Attendence/attendance_screen.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/auth/login_option_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/expense/expense_screen.dart';
import 'package:marco/view/document/user_document_screen.dart';
import 'package:marco/view/tenant/tenant_selection_screen.dart';
class AuthMiddleware extends GetMiddleware {
@override
RouteSettings? redirect(String? route) {
return AuthService.isLoggedIn
? null
: RouteSettings(name: '/auth/login-option');
if (!AuthService.isLoggedIn) {
if (route != '/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(),
middlewares: [AuthMiddleware()]),
GetPage(
name: '/home',
name: '/dashboard',
page: () => DashboardScreen(), // or your actual home screen
middlewares: [AuthMiddleware()],
),
GetPage(
name: '/select-tenant',
page: () => const TenantSelectionScreen(),
middlewares: [AuthMiddleware()]),
// Dashboard
GetPage(
@ -72,7 +85,7 @@ getPageRoute() {
name: '/dashboard/expense-main-page',
page: () => ExpenseMainScreen(),
middlewares: [AuthMiddleware()]),
// Documents
// Documents
GetPage(
name: '/dashboard/document-main-page',
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/attendence_action_button.dart';
import 'package:marco/helpers/utils/attendance_actions.dart';
import 'package:marco/helpers/services/app_logger.dart';
class AttendanceLogsTab extends StatefulWidget {
final AttendanceController controller;
@ -94,16 +93,6 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
} else {
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;
}
@ -116,7 +105,7 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
final showPendingOnly = widget.controller.showPendingOnly.value;
final filteredLogs = showPendingOnly
? allLogs
.where((emp) => emp.activity == 1 || emp.activity == 2)
.where((emp) => emp.activity == 1 )
.toList()
: allLogs;

View File

@ -47,6 +47,7 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
Future<void> _loadData(String projectId) async {
try {
attendanceController.selectedTab = 'todaysAttendance';
await attendanceController.loadAttendanceData(projectId);
attendanceController.update(['attendance_dashboard_controller']);
} catch (e) {
@ -56,7 +57,24 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
Future<void> _refreshData() async {
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() {
@ -195,15 +213,26 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
final selectedProjectId =
projectController.selectedProjectId.value;
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) {
try {
await attendanceController
.fetchEmployeesByProject(selectedProjectId);
await attendanceController
.fetchAttendanceLogs(selectedProjectId);
await attendanceController
.fetchRegularizationLogs(selectedProjectId);
await attendanceController.fetchTodaysAttendance(
selectedProjectId,
);
await attendanceController.fetchAttendanceLogs(
selectedProjectId,
);
await attendanceController.fetchRegularizationLogs(
selectedProjectId,
);
await attendanceController
.fetchProjectData(selectedProjectId);
} catch (_) {}
@ -214,6 +243,11 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
if (selectedView != null && selectedView != selectedTab) {
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),
Center(
child: MyButton.rounded(
onPressed: controller.onLogin,
elevation: 2,
padding: MySpacing.xy(80, 16),
borderRadiusAll: 10,
backgroundColor: contentTheme.brandRed,
child: MyText.labelLarge(
'Login',
fontWeight: 700,
color: contentTheme.onPrimary,
),
),
child: Obx(() {
final isLoading = controller.isLoading.value;
return MyButton.rounded(
onPressed: isLoading
? null
: controller.onLogin,
elevation: 2,
padding: MySpacing.xy(80, 16),
borderRadiusAll: 10,
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: (_) {
return Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
return const Center(child: LinearProgressIndicator());
}
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/view/layouts/layout.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';
class DashboardScreen extends StatefulWidget {
@ -85,13 +84,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
/// Project Progress Chart Section
Widget _buildProjectProgressChartSection() {
return Obx(() {
if (dashboardController.isProjectLoading.value) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: SkeletonLoaders.chartSkeletonLoader(),
);
}
if (dashboardController.projectChartData.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
@ -102,7 +94,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
}
return ClipRRect(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
child: SizedBox(
height: 400,
child: ProjectProgressChart(
@ -116,15 +108,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
/// Attendance Chart Section
Widget _buildAttendanceChartSection() {
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");
if (!isAttendanceAllowed) {
@ -141,7 +124,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
child: IgnorePointer(
ignoring: !isProjectSelected,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
child: SizedBox(
height: 400,
child: AttendanceDashboardChart(),
@ -198,7 +181,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
width: width,
height: 100,
paddingAll: 5,
borderRadiusAll: 10,
borderRadiusAll: 5,
border: Border.all(color: Colors.grey.withOpacity(0.15)),
child: Column(
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) {
return Obx(() {
if (menuController.isLoading.value) {
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(
padding: const EdgeInsets.all(16),
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 = [
_StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success,
DashboardScreen.attendanceRoute),
@ -255,39 +242,50 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
DashboardScreen.documentMainPageRoute),
];
final projectController = Get.find<ProjectController>();
final isProjectSelected = projectController.selectedProject != null;
// Safe menu check function to avoid exceptions
bool _isMenuAllowed(String menuTitle) {
try {
return menuController.menuItems.isNotEmpty
? menuController.isMenuAllowed(menuTitle)
: false;
} catch (e) {
return false;
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isProjectSelected) _buildNoProjectMessage(),
Wrap(
spacing: 6,
runSpacing: 6,
children: stats
.where((stat) {
// Always allow Documents
if (stat.title == "Documents") return true;
LayoutBuilder(
builder: (context, constraints) {
int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 8);
double cardWidth =
(constraints.maxWidth - (crossAxisCount - 1) * 6) /
crossAxisCount;
// For all other menus, respect sidebar permissions
return menuController.isMenuAllowed(stat.title);
})
.map((stat) => _buildStatCard(stat, isProjectSelected))
.toList(),
return Wrap(
spacing: 6,
runSpacing: 6,
alignment: WrapAlignment.start,
children: stats
.where((stat) => _isMenuAllowed(stat.title))
.map((stat) =>
_buildStatCard(stat, isProjectSelected, cardWidth))
.toList(),
);
},
),
],
);
});
}
/// Stat Card (Compact with wrapping text)
/// Stat Card (Compact with wrapping text)
Widget _buildStatCard(_StatItem statItem, bool isProjectSelected) {
const double cardWidth = 80;
const double cardHeight = 70;
/// Stat Card (Compact + Small)
Widget _buildStatCard(
_StatItem statItem, bool isProjectSelected, double width) {
const double cardHeight = 60;
// Attendance should always be enabled
final bool isEnabled = statItem.title == "Attendance" || isProjectSelected;
return Opacity(
@ -296,30 +294,28 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
ignoring: !isEnabled,
child: InkWell(
onTap: () => _handleStatCardTap(statItem, isEnabled),
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(5),
child: MyCard.bordered(
width: cardWidth,
width: width,
height: cardHeight,
paddingAll: 4,
borderRadiusAll: 8,
borderRadiusAll: 5,
border: Border.all(color: Colors.grey.withOpacity(0.15)),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildStatCardIconCompact(statItem),
_buildStatCardIconCompact(statItem, size: 12),
MySpacing.height(4),
Expanded(
child: Center(
child: Text(
statItem.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 10,
overflow: TextOverflow.visible,
),
maxLines: 2,
softWrap: true,
Flexible(
child: Text(
statItem.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 8,
overflow: TextOverflow.visible,
),
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
void _handleStatCardTap(_StatItem statItem, bool isEnabled) {
if (!isEnabled) {
@ -346,21 +355,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
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 {

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/model/directory/add_contact_bottom_sheet.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
String _convertDeltaToHtml(dynamic delta) {
@ -63,6 +65,7 @@ String _convertDeltaToHtml(dynamic delta) {
class ContactDetailScreen extends StatefulWidget {
final ContactModel contact;
const ContactDetailScreen({super.key, required this.contact});
@override
State<ContactDetailScreen> createState() => _ContactDetailScreenState();
}
@ -70,16 +73,28 @@ class ContactDetailScreen extends StatefulWidget {
class _ContactDetailScreenState extends State<ContactDetailScreen> {
late final DirectoryController directoryController;
late final ProjectController projectController;
late ContactModel contact;
late Rx<ContactModel> contactRx;
@override
void initState() {
super.initState();
directoryController = Get.find<DirectoryController>();
projectController = Get.find<ProjectController>();
contact = widget.contact;
WidgetsBinding.instance.addPostFrameCallback((_) {
directoryController.fetchCommentsForContact(contact.id);
contactRx = widget.contact.obs;
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSubHeader(),
Obx(() => _buildSubHeader(contactRx.value)),
const Divider(height: 1, thickness: 0.5, color: Colors.grey),
Expanded(
child: TabBarView(children: [
_buildDetailsTab(),
_buildCommentsTab(context),
Obx(() => _buildDetailsTab(contactRx.value)),
_buildCommentsTab(),
]),
),
],
@ -135,9 +150,9 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
MyText.titleLarge('Contact Profile',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (p) => ProjectLabel(p.selectedProject?.name),
),
GetBuilder<ProjectController>(builder: (p) {
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 lastName =
contact.name.split(" ").length > 1 ? contact.name.split(" ").last : "";
@ -159,10 +174,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
children: [
Row(children: [
Avatar(
firstName: firstName,
lastName: lastName,
size: 35,
backgroundColor: Colors.indigo),
firstName: firstName,
lastName: lastName,
size: 35,
),
MySpacing.width(12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -188,7 +203,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
),
tabs: const [
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 bucketNames = contact.bucketIds
.map((id) => directoryController.contactBuckets
@ -249,7 +264,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(12),
// BASIC INFO CARD
_infoCard("Basic Info", [
multiRows(
items:
@ -273,20 +287,17 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
),
_iconInfoRow(Icons.location_on, "Address", contact.address),
]),
// ORGANIZATION CARD
_infoCard("Organization", [
_iconInfoRow(
Icons.business, "Organization", contact.organization),
_iconInfoRow(Icons.category, "Category", category),
]),
// META INFO CARD
_infoCard("Meta Info", [
_iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"),
_iconInfoRow(Icons.folder_shared, "Contact Buckets",
bucketNames.isNotEmpty ? bucketNames : "-"),
_iconInfoRow(Icons.work_outline, "Projects", projectNames),
]),
// DESCRIPTION CARD
_infoCard("Description", [
MySpacing.height(6),
Align(
@ -318,7 +329,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
final updated = directoryController.allContacts
.firstWhereOrNull((c) => c.id == contact.id);
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(() {
final contactId = contact.id;
if (!directoryController.contactCommentsMap.containsKey(contactId)) {
return const Center(child: CircularProgressIndicator());
}
final comments = directoryController
.getCommentsForContact(contactId)
.reversed
.toList();
final contactId = contactRx.value.id;
final comments = directoryController.combinedComments(contactId);
final editingId = directoryController.editingCommentId.value;
return Stack(
children: [
MyRefreshIndicator(
onRefresh: () async {
await directoryController.fetchCommentsForContact(contactId);
},
child: comments.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.6,
child: Center(
child: MyText.bodyLarge(
"No comments yet.",
color: Colors.grey,
),
),
),
],
)
: Padding(
comments.isEmpty
? Center(
child: MyText.bodyLarge("No notes yet.", color: Colors.grey),
)
: MyRefreshIndicator(
onRefresh: () async {
await directoryController.fetchCommentsForContact(contactId,
active: true);
await directoryController.fetchCommentsForContact(contactId,
active: false);
},
child: Padding(
padding: MySpacing.xy(12, 12),
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
@ -373,13 +369,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
itemCount: comments.length,
separatorBuilder: (_, __) => MySpacing.height(14),
itemBuilder: (_, index) => _buildCommentItem(
comments[index],
editingId,
contact.id,
),
comments[index], editingId, contactId),
),
),
),
),
if (editingId == null)
Positioned(
bottom: 20,
@ -392,15 +385,15 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
isScrollControlled: true,
);
if (result == true) {
await directoryController
.fetchCommentsForContact(contactId);
await directoryController.fetchCommentsForContact(contactId,
active: true);
await directoryController.fetchCommentsForContact(contactId,
active: false);
}
},
icon: const Icon(Icons.add_comment, color: Colors.white),
label: const Text(
"Add Comment",
style: TextStyle(color: Colors.white),
),
label: const Text("Add Note",
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 initials = comment.createdBy.firstName.isNotEmpty
? comment.createdBy.firstName[0].toUpperCase()
: "?";
final decodedDelta = HtmlToDelta().convert(comment.note);
final quillController = isEditing
? quill.QuillController(
@ -421,88 +416,190 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
)
: null;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: MySpacing.xy(8, 7),
decoration: BoxDecoration(
color: isEditing ? Colors.indigo[50] : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isEditing ? Colors.indigo : Colors.grey.shade300,
width: 1.2,
final isInactive = !comment.isActive;
return Container(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.grey.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
boxShadow: const [
BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2))
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Row
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(firstName: initials, lastName: '', size: 36),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium("By: ${comment.createdBy.firstName}",
fontWeight: 600, color: Colors.indigo[800]),
MySpacing.height(4),
MyText.bodySmall(
DateTimeUtils.convertUtcToLocal(
comment.createdAt.toString(),
format: 'dd MMM yyyy, hh:mm a',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🧑 Header
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(
firstName: initials,
lastName: '',
size: 40,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"${comment.createdBy.firstName} ${comment.createdBy.lastName}",
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 15,
color: isInactive ? Colors.grey : Colors.black87,
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(
icon: Icon(
isEditing ? Icons.close : Icons.edit,
size: 20,
color: Colors.indigo,
),
onPressed: () {
directoryController.editingCommentId.value =
isEditing ? null : comment.id;
// Action buttons
if (!isInactive)
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_outlined,
size: 18, color: Colors.indigo),
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(

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/notes_view.dart';
class DirectoryMainScreen extends StatelessWidget {
DirectoryMainScreen({super.key});
class DirectoryMainScreen extends StatefulWidget {
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 NotesController notesController = Get.put(NotesController());
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
backgroundColor: const Color(0xFFF5F5F5),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
@ -79,116 +99,34 @@ class DirectoryMainScreen extends StatelessWidget {
),
),
),
body: SafeArea(
child: Column(
children: [
// Toggle between Directory and Notes
Padding(
padding: MySpacing.fromLTRB(8, 12, 8, 5),
child: Obx(() {
final isNotesView = controller.isNotesView.value;
return Container(
padding: EdgeInsets.all(2),
decoration: BoxDecoration(
color: const Color(0xFFF0F0F0),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
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,
),
),
],
),
),
),
),
],
),
);
}),
body: Column(
children: [
// ---------------- TabBar ----------------
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.red,
tabs: const [
Tab(text: "Directory"),
Tab(text: "Notes"),
],
),
),
// Main View
Expanded(
child: Obx(() =>
controller.isNotesView.value ? NotesView() : DirectoryView()),
// ---------------- TabBarView ----------------
Expanded(
child: TabBarView(
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/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
class DirectoryView extends StatefulWidget {
@override
@ -89,7 +90,7 @@ class _DirectoryViewState extends State<DirectoryView> {
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(5),
),
child: Column(
mainAxisSize: MainAxisSize.min,
@ -114,7 +115,7 @@ class _DirectoryViewState extends State<DirectoryView> {
backgroundColor: Colors.grey[300],
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
),
padding: const EdgeInsets.symmetric(vertical: 12),
),
@ -129,7 +130,7 @@ class _DirectoryViewState extends State<DirectoryView> {
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
),
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
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
floatingActionButton: FloatingActionButton(
backgroundColor: Colors.grey[100],
floatingActionButton: FloatingActionButton.extended(
heroTag: 'createContact',
backgroundColor: Colors.red,
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(
children: [
// Search + Filter + More menu
Padding(
padding: MySpacing.xy(8, 8),
child: Row(
@ -177,9 +202,8 @@ class _DirectoryViewState extends State<DirectoryView> {
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: searchController,
builder: (context, value, _) {
if (value.text.isEmpty) {
if (value.text.isEmpty)
return const SizedBox.shrink();
}
return IconButton(
icon: const Icon(Icons.clear,
size: 20, color: Colors.grey),
@ -195,11 +219,11 @@ class _DirectoryViewState extends State<DirectoryView> {
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
@ -217,7 +241,7 @@ class _DirectoryViewState extends State<DirectoryView> {
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(5),
),
child: IconButton(
icon: Icon(Icons.tune,
@ -231,7 +255,7 @@ class _DirectoryViewState extends State<DirectoryView> {
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(20)),
top: Radius.circular(5)),
),
builder: (_) =>
const DirectoryFilterBottomSheet(),
@ -262,15 +286,14 @@ class _DirectoryViewState extends State<DirectoryView> {
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(5),
),
child: PopupMenuButton<int>(
padding: EdgeInsets.zero,
icon: const Icon(Icons.more_vert,
size: 20, color: Colors.black87),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
borderRadius: BorderRadius.circular(5)),
itemBuilder: (context) {
List<PopupMenuEntry<int>> menuItems = [];
@ -279,17 +302,13 @@ class _DirectoryViewState extends State<DirectoryView> {
const PopupMenuItem<int>(
enabled: false,
height: 30,
child: Text(
"Actions",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
child: Text("Actions",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey)),
),
);
// Conditionally show Create Bucket option
if (permissionController
.hasPermission(Permissions.directoryAdmin) ||
permissionController
@ -355,13 +374,10 @@ class _DirectoryViewState extends State<DirectoryView> {
const PopupMenuItem<int>(
enabled: false,
height: 30,
child: Text(
"Preferences",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
child: Text("Preferences",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey)),
),
);
@ -375,7 +391,8 @@ class _DirectoryViewState extends State<DirectoryView> {
const Icon(Icons.visibility_off_outlined,
size: 20, color: Colors.black87),
const SizedBox(width: 10),
const Expanded(child: Text('Show Inactive')),
const Expanded(
child: Text('Show Deleted Contacts')),
Switch.adaptive(
value: !controller.isActive.value,
activeColor: Colors.indigo,
@ -397,231 +414,347 @@ class _DirectoryViewState extends State<DirectoryView> {
],
),
),
// Contact List
Expanded(
child: Obx(() {
return MyRefreshIndicator(
onRefresh: _refreshDirectory,
backgroundColor: Colors.indigo,
color: Colors.white,
child: controller.isLoading.value
? ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: 10,
separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, __) =>
SkeletonLoaders.contactSkeletonCard(),
)
: controller.filteredContacts.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height:
MediaQuery.of(context).size.height * 0.6,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.contact_page_outlined,
size: 60, color: Colors.grey),
const SizedBox(height: 12),
MyText.bodyMedium('No contacts found.',
fontWeight: 500),
],
),
),
),
],
)
: 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();
onRefresh: _refreshDirectory,
backgroundColor: Colors.indigo,
color: Colors.white,
child: controller.isLoading.value
? ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: 10,
separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, __) =>
SkeletonLoaders.contactSkeletonCard(),
)
: controller.filteredContacts.isEmpty
? _buildEmptyState()
: 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 isDeleted = !controller
.isActive.value; // mark deleted contacts
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(
onTap: () {
Get.to(() =>
ContactDetailScreen(contact: contact));
},
child: Padding(
padding:
const EdgeInsets.fromLTRB(12, 10, 12, 0),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Avatar(
firstName: firstName,
lastName: lastName,
size: 35),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
MyText.titleSmall(contact.name,
fontWeight: 600,
overflow:
TextOverflow.ellipsis),
MyText.bodySmall(
contact.organization,
color: Colors.grey[700],
overflow:
TextOverflow.ellipsis),
MySpacing.height(8),
if (contact
.contactEmails.isNotEmpty)
GestureDetector(
onTap: () =>
LauncherUtils.launchEmail(
contact
.contactEmails
.first
.emailAddress),
onLongPress: () => LauncherUtils
.copyToClipboard(
contact.contactEmails.first
.emailAddress,
typeLabel: 'Email',
return Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
elevation: 3,
shadowColor: Colors.grey.withOpacity(0.3),
color: Colors.white,
child: InkWell(
borderRadius: BorderRadius.circular(5),
onTap: isDeleted
? null
: () => Get.to(() =>
ContactDetailScreen(
contact: contact)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
// Avatar
Avatar(
firstName: firstName,
lastName: lastName,
size: 40,
backgroundColor: isDeleted
? Colors.grey.shade400
: null,
),
MySpacing.width(12),
// Contact Info
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
MyText.titleSmall(
contact.name,
fontWeight: 600,
overflow:
TextOverflow.ellipsis,
color: isDeleted
? Colors.grey
: Colors.black87,
),
child: Padding(
padding:
const EdgeInsets.only(
bottom: 4),
child: Row(
children: [
const Icon(
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,
),
),
],
),
MyText.bodySmall(
contact.organization,
color: isDeleted
? Colors.grey
: Colors.grey[700],
overflow:
TextOverflow.ellipsis,
),
),
if (contact
.contactPhones.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
bottom: 8, top: 4),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => LauncherUtils
.launchPhone(contact
.contactPhones
.first
.phoneNumber),
onLongPress: () =>
LauncherUtils
.copyToClipboard(
contact
.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(
MySpacing.height(6),
if (contact
.contactEmails.isNotEmpty)
Padding(
padding:
const EdgeInsets.only(
bottom: 4),
child: GestureDetector(
onTap: isDeleted
? null
: () => LauncherUtils
.launchEmail(contact
.contactEmails
.first
.emailAddress),
onLongPress: isDeleted
? null
: () => LauncherUtils
.copyToClipboard(
contact
.contactPhones
.contactEmails
.first
.phoneNumber,
overflow:
TextOverflow
.ellipsis,
color: Colors
.indigo,
decoration:
TextDecoration
.underline,
.emailAddress,
typeLabel:
'Email',
),
child: Row(
children: [
Icon(
Icons
.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
.contactPhones
.first
.phoneNumber),
child: const FaIcon(
FontAwesomeIcons
.whatsapp,
color: Colors.green,
size: 16,
),
),
],
),
],
),
),
// Actions Column (Arrow + Icons)
Column(
children: [
IconButton(
icon: Icon(
isDeleted
? Icons.restore
: Icons.delete,
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) ...[
MySpacing.height(2),
MyText.labelSmall(tags.join(', '),
color: Colors.grey[500],
maxLines: 1,
overflow:
TextOverflow.ellipsis),
const SizedBox(height: 4),
Icon(
Icons.arrow_forward_ios,
color: Colors.grey,
size: 20,
)
],
],
),
),
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_delta_from_html/flutter_quill_delta_from_html.dart';
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/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/avatar.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/my_confirmation_dialog.dart';
class NotesView extends StatelessWidget {
final NotesController controller = Get.find();
@ -67,15 +68,36 @@ class NotesView extends StatelessWidget {
}
if (inList) buffer.write('</ul>');
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
Widget build(BuildContext context) {
return Column(
children: [
/// 🔍 Search + Refresh (Top Row)
/// 🔍 Search Field
Padding(
padding: MySpacing.xy(8, 8),
child: Row(
@ -94,11 +116,11 @@ class NotesView extends StatelessWidget {
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
@ -109,7 +131,7 @@ class NotesView extends StatelessWidget {
),
),
/// 📄 Notes List View
/// 📄 Notes List
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
@ -121,25 +143,17 @@ class NotesView extends StatelessWidget {
if (notes.isEmpty) {
return MyRefreshIndicator(
onRefresh: _refreshNotes,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.6,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.note_alt_outlined,
size: 60, color: Colors.grey),
const SizedBox(height: 12),
MyText.bodyMedium('No notes found.',
fontWeight: 500),
],
),
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: ConstrainedBox(
constraints:
BoxConstraints(minHeight: constraints.maxHeight),
child: Center(child: _buildEmptyState()),
),
),
],
);
},
),
);
}
@ -187,89 +201,187 @@ class NotesView extends StatelessWidget {
duration: const Duration(milliseconds: 250),
padding: MySpacing.xy(12, 12),
decoration: BoxDecoration(
color: isEditing ? Colors.indigo[50] : Colors.white,
color: isEditing
? Colors.indigo[50]
: note.isActive
? Colors.white
: Colors.grey.shade100,
border: Border.all(
color:
isEditing ? Colors.indigo : Colors.grey.shade300,
color: note.isActive
? (isEditing
? Colors.indigo
: Colors.grey.shade300)
: Colors.grey.shade400,
width: 1.1,
),
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 4,
offset: Offset(0, 2)),
color: Colors.black12,
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// Header Row
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(
firstName: initials, lastName: '', size: 40),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall(
"${note.contactName} (${note.organizationName})",
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.indigo[800],
// Header & Note content (fade them if inactive)
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: note.isActive ? 1.0 : 0.6,
child: IgnorePointer(
ignoring: !note.isActive,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Avatar(
firstName: initials,
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),
/// Content
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: Colors.black87,
// Action buttons (always fully visible)
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (note.isActive) ...[
IconButton(
icon: Icon(
isEditing ? Icons.close : Icons.edit,
color: Colors.indigo,
size: 20,
),
padding: EdgeInsets.all(2),
constraints: const BoxConstraints(),
onPressed: () {
controller.editingNoteId.value =
isEditing ? null : note.id;
},
),
},
),
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> {
final DocumentDetailsController controller =
Get.put(DocumentDetailsController());
final PermissionController permissionController =
Get.find<PermissionController>();
Get.find<DocumentDetailsController>();
final permissionController = Get.put(PermissionController());
@override
void initState() {
super.initState();
@ -108,7 +109,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
@ -190,7 +191,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius:
BorderRadius.vertical(top: Radius.circular(20)),
BorderRadius.vertical(top: Radius.circular(5)),
),
builder: (_) {
return DocumentEditBottomSheet(
@ -246,7 +247,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
@ -280,7 +281,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
@ -377,7 +378,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
margin: const EdgeInsets.only(right: 6, bottom: 6),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
),
child: MyText.bodySmall(
label,

View File

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

View File

@ -11,6 +11,7 @@ import 'package:marco/helpers/utils/launcher_utils.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/model/employees/add_employee_bottom_sheet.dart';
class EmployeeDetailPage extends StatefulWidget {
final String employeeId;
@ -29,8 +30,8 @@ class EmployeeDetailPage extends StatefulWidget {
class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
final EmployeesScreenController controller =
Get.put(EmployeesScreenController());
final PermissionController _permissionController =
Get.find<PermissionController>();
final PermissionController permissionController =
Get.put(PermissionController());
@override
void initState() {
@ -92,8 +93,9 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
fontWeight: FontWeight.normal,
color: (isEmail || isPhone) ? Colors.indigo : Colors.black54,
fontSize: 14,
decoration:
(isEmail || isPhone) ? TextDecoration.underline : TextDecoration.none,
decoration: (isEmail || isPhone)
? TextDecoration.underline
: TextDecoration.none,
),
),
);
@ -151,7 +153,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
return Card(
elevation: 3,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 16, 12, 16),
child: Column(
@ -231,7 +233,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
lastName: employee.lastName,
size: 45,
),
MySpacing.width(16),
MySpacing.width(12),
Expanded(
child: Column(
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),
@ -259,7 +293,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
);
}),
floatingActionButton: Obx(() {
if (!_permissionController.hasPermission(Permissions.assignToProject)) {
if (!permissionController.hasPermission(Permissions.assignToProject)) {
return const SizedBox.shrink();
}
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/widgets/my_refresh_indicator.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 {
const EmployeesScreen({super.key});
@ -27,10 +29,12 @@ class EmployeesScreen extends StatefulWidget {
class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
final EmployeesScreenController _employeeController =
Get.put(EmployeesScreenController());
final PermissionController _permissionController =
Get.find<PermissionController>();
final PermissionController permissionController =
Get.put(PermissionController());
final TextEditingController _searchController = TextEditingController();
final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs;
final OrganizationController _organizationController =
Get.put(OrganizationController());
@override
void initState() {
@ -44,13 +48,19 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
Future<void> _initEmployees() async {
final projectId = Get.find<ProjectController>().selectedProject?.id;
final orgId = _organizationController.selectedOrganization.value?.id;
if (projectId != null) {
await _organizationController.fetchOrganizations(projectId);
}
if (_employeeController.isAllEmployeeSelected.value) {
_employeeController.selectedProjectId = null;
await _employeeController.fetchAllEmployees();
await _employeeController.fetchAllEmployees(organizationId: orgId);
} else if (projectId != null) {
_employeeController.selectedProjectId = projectId;
await _employeeController.fetchEmployeesByProject(projectId);
await _employeeController.fetchEmployeesByProject(projectId,
organizationId: orgId);
} else {
_employeeController.clearEmployees();
}
@ -61,14 +71,16 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
Future<void> _refreshEmployees() async {
try {
final projectId = Get.find<ProjectController>().selectedProject?.id;
final orgId = _organizationController.selectedOrganization.value?.id;
final allSelected = _employeeController.isAllEmployeeSelected.value;
_employeeController.selectedProjectId = allSelected ? null : projectId;
if (allSelected) {
await _employeeController.fetchAllEmployees();
await _employeeController.fetchAllEmployees(organizationId: orgId);
} else if (projectId != null) {
await _employeeController.fetchEmployeesByProject(projectId);
await _employeeController.fetchEmployeesByProject(projectId,
organizationId: orgId);
} else {
_employeeController.clearEmployees();
}
@ -236,43 +248,95 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
}
Widget _buildFloatingActionButton() {
if (!_permissionController.hasPermission(Permissions.manageEmployees)) {
return const SizedBox.shrink();
}
return Obx(() {
// Show nothing while permissions are loading
if (permissionController.isLoading.value) {
return const SizedBox.shrink();
}
return InkWell(
onTap: _onAddNewEmployee,
borderRadius: BorderRadius.circular(28),
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))
],
// Show FAB only if user has Manage Employees permission
final hasPermission =
permissionController.hasPermission(Permissions.manageEmployees);
if (!hasPermission) {
return const SizedBox.shrink();
}
return InkWell(
onTap: _onAddNewEmployee,
borderRadius: BorderRadius.circular(28),
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() {
return Padding(
padding: MySpacing.x(flexSpacing),
child: Row(
padding: MySpacing.x(15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _buildSearchField()),
const SizedBox(width: 8),
_buildPopupMenu(),
// Search Field Row
Row(
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() {
if (!_permissionController.hasPermission(Permissions.viewAllEmployees)) {
return const SizedBox.shrink();
}
return Obx(() {
if (permissionController.isLoading.value ||
!permissionController.hasPermission(Permissions.viewAllEmployees)) {
return const SizedBox.shrink();
}
return PopupMenuButton<String>(
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.tune, color: Colors.black),
Obx(() => _employeeController.isAllEmployeeSelected.value
? Positioned(
right: -1,
top: -1,
child: Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Colors.red, shape: BoxShape.circle),
return PopupMenuButton<String>(
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.tune, color: Colors.black),
Obx(() => _employeeController.isAllEmployeeSelected.value
? Positioned(
right: -1,
top: -1,
child: Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
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 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 Text('All Employees'),
],
),
),
),
),
],
);
],
);
});
}
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/model/employees/employee_info.dart';
import 'package:timeline_tile/timeline_tile.dart';
class ExpenseDetailScreen extends StatefulWidget {
final String expenseId;
const ExpenseDetailScreen({super.key, required this.expenseId});
@ -32,7 +33,7 @@ class ExpenseDetailScreen extends StatefulWidget {
class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
final controller = Get.put(ExpenseDetailController());
final projectController = Get.find<ProjectController>();
final permissionController = Get.find<PermissionController>();
final permissionController = Get.put(PermissionController());
EmployeeInfo? employeeInfo;
final RxBool canSubmit = false.obs;
@ -105,7 +106,7 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
borderRadius: BorderRadius.circular(5)),
elevation: 3,
child: Padding(
padding: const EdgeInsets.symmetric(
@ -115,20 +116,20 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
children: [
_InvoiceHeader(expense: expense),
const Divider(height: 30, thickness: 1.2),
InvoiceLogs(logs: expense.expenseLogs),
const Divider(height: 30, thickness: 1.2),
_InvoiceParties(expense: expense),
const Divider(height: 30, thickness: 1.2),
_InvoiceDetailsTable(expense: expense),
const Divider(height: 30, thickness: 1.2),
_InvoiceDocuments(documents: expense.documents),
const Divider(height: 30, thickness: 1.2),
_InvoiceTotals(
expense: expense,
formattedAmount: formattedAmount,
statusColor: statusColor,
),
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 FloatingActionButton(
return FloatingActionButton.extended(
onPressed: () async {
final editData = {
'id': expense.id,
@ -195,8 +196,9 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
await controller.fetchExpenseDetails();
},
backgroundColor: Colors.red,
tooltip: 'Edit Expense',
child: const Icon(Icons.edit),
icon: const Icon(Icons.edit),
label: MyText.bodyMedium(
"Edit Expense", fontWeight: 600, color: Colors.white),
);
}),
bottomNavigationBar: Obx(() {
@ -269,7 +271,7 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
minimumSize: const Size(100, 40),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
backgroundColor: buttonColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
),
onPressed: () async {
const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27';
@ -278,7 +280,7 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
borderRadius: BorderRadius.vertical(top: Radius.circular(5))),
builder: (context) => ReimbursementBottomSheet(
expenseId: expense.id,
statusId: next.id,
@ -468,7 +470,7 @@ class _InvoiceHeader extends StatelessWidget {
Container(
decoration: BoxDecoration(
color: statusColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(8)),
borderRadius: BorderRadius.circular(5)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
child: Row(
children: [
@ -602,7 +604,7 @@ class _InvoiceDocuments extends StatelessWidget {
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(6),
borderRadius: BorderRadius.circular(5),
color: Colors.grey.shade100,
),
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(
padding: const EdgeInsets.all(12.0),
child: Column(
@ -696,17 +699,20 @@ class InvoiceLogs extends StatelessWidget {
const SizedBox(height: 8),
Row(
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),
MyText.bodySmall(formattedDate, color: Colors.grey[700]),
MyText.bodySmall(formattedDate,
color: Colors.grey[700]),
],
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(6),
borderRadius: BorderRadius.circular(5),
),
child: MyText.bodySmall(
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();
}
class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
bool isHistoryView = false;
class _ExpenseMainScreenState extends State<ExpenseMainScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final searchController = TextEditingController();
final expenseController = Get.put(ExpenseController());
final projectController = Get.find<ProjectController>();
final permissionController = Get.find<PermissionController>();
final permissionController = Get.put(PermissionController());
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
expenseController.fetchExpenses();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _refreshExpenses() async {
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 now = DateTime.now();
@ -61,7 +69,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
}).toList()
..sort((a, b) => b.transactionDate.compareTo(a.transactionDate));
return isHistoryView
return isHistory
? filtered
.where((e) =>
e.transactionDate.isBefore(DateTime(now.year, now.month)))
@ -73,88 +81,130 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
.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();
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(),
),
),
);
}),
)
],
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: ExpenseAppBar(projectController: projectController),
body: Column(
children: [
// ---------------- TabBar ----------------
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.red,
tabs: const [
Tab(text: "Current Month"),
Tab(text: "History"),
],
),
),
),
// FAB only if user has expenseUpload permission
floatingActionButton:
permissionController.hasPermission(Permissions.expenseUpload)
? FloatingActionButton(
backgroundColor: Colors.red,
onPressed: showAddExpenseBottomSheet,
child: const Icon(Icons.add, color: Colors.white),
)
: null,
);
// ---------------- Gray background for rest ----------------
Expanded(
child: Container(
color: Colors.grey[100],
child: Column(
children: [
// ---------------- Search ----------------
Padding(
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(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
),
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
@ -133,12 +133,48 @@ class _LayoutState extends State<Layout> {
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
Images.logoDark,
height: 50,
width: 50,
fit: BoxFit.contain,
borderRadius: BorderRadius.circular(5),
child: Stack(
clipBehavior: Clip.none,
children: [
Image.asset(
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),
@ -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(
clipBehavior: Clip.none,
alignment: Alignment.center,
@ -268,7 +289,7 @@ class _LayoutState extends State<Layout> {
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(10),
padding: const EdgeInsets.all(5),
color: Colors.white,
child: _buildProjectList(context, isMobile),
),
@ -285,7 +306,7 @@ class _LayoutState extends State<Layout> {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
),
margin: EdgeInsets.zero,
child: Padding(
@ -297,7 +318,7 @@ class _LayoutState extends State<Layout> {
width: 50,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(5),
),
),
const SizedBox(width: 12),
@ -343,11 +364,11 @@ class _LayoutState extends State<Layout> {
right: 16,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
),
padding: const EdgeInsets.all(10),
child: _buildProjectList(context, isMobile),
@ -397,7 +418,7 @@ class _LayoutState extends State<Layout> {
? Colors.blueAccent.withOpacity(0.1)
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
borderRadius: BorderRadius.circular(5),
),
visualDensity: const VisualDensity(vertical: -4),
);

View File

@ -82,7 +82,7 @@ class _LeftBarState extends State<LeftBar>
child: Padding(
padding: EdgeInsets.only(top: 50),
child: InkWell(
onTap: () => Get.toNamed('/home'),
onTap: () => Get.toNamed('/dashboard'),
child: Image.asset(
(ThemeCustomizer.instance.theme == ThemeMode.light
? (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/controller/auth/mpin_controller.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 {
final bool isCondensed;
@ -80,6 +85,10 @@ class _UserProfileBarState extends State<UserProfileBar>
_isLoading
? const _LoadingSection()
: _userProfileSection(isCondensed),
// --- SWITCH TENANT ROW BELOW AVATAR ---
if (!_isLoading && !isCondensed) _switchTenantRow(),
MySpacing.height(12),
Divider(
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) {
final padding = MySpacing.fromLTRB(
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/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/navigation_services.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
@ -19,22 +18,21 @@ class MyApp extends StatelessWidget {
Future<String> _getInitialRoute() async {
try {
if (!AuthService.isLoggedIn) {
final token = LocalStorage.getJwtToken();
if (token == null || token.isEmpty) {
logSafe("User not logged in. Routing to /auth/login-option");
return "/auth/login-option";
}
final bool hasMpin = LocalStorage.getIsMpin();
logSafe("MPIN enabled: $hasMpin", );
if (hasMpin) {
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";
} else {
logSafe("MPIN not enabled. Routing to /dashboard");
return "/dashboard";
}
logSafe("No MPIN. Routing to /dashboard");
return "/dashboard";
} catch (e, stacktrace) {
logSafe("Error determining initial route",
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 =
Get.put(DailyTaskController());
final PermissionController permissionController =
Get.find<PermissionController>();
Get.put(PermissionController());
final ProjectController projectController = Get.find<ProjectController>();
final ScrollController _scrollController = ScrollController();
@override
void 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;
if (initialProjectId.isNotEmpty) {
dailyTaskController.selectedProjectId = initialProjectId;
dailyTaskController.fetchTaskData(initialProjectId);
}
ever<String>(
projectController.selectedProjectId,
(newProjectId) async {
if (newProjectId.isNotEmpty &&
newProjectId != dailyTaskController.selectedProjectId) {
dailyTaskController.selectedProjectId = newProjectId;
await dailyTaskController.fetchTaskData(newProjectId);
dailyTaskController.update(['daily_progress_report_controller']);
}
},
);
// Update when project changes
ever<String>(projectController.selectedProjectId, (newProjectId) async {
if (newProjectId.isNotEmpty &&
newProjectId != dailyTaskController.selectedProjectId) {
dailyTaskController.selectedProjectId = newProjectId;
await dailyTaskController.fetchTaskData(newProjectId);
dailyTaskController.update(['daily_progress_report_controller']);
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
@ -131,8 +151,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
child: MyRefreshIndicator(
onRefresh: _refreshData,
child: CustomScrollView(
physics:
const AlwaysScrollableScrollPhysics(),
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: GetBuilder<DailyTaskController>(
@ -143,7 +162,37 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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: MySpacing.x(8),
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 {
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,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => DailyProgressReportFilter(
builder: (context) => DailyTaskFilterBottomSheet(
controller: dailyTaskController,
permissionController: permissionController,
),
);
@ -299,10 +307,12 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
final isLoading = dailyTaskController.isLoading.value;
final groupedTasks = dailyTaskController.groupedDailyTasks;
if (isLoading) {
// 🟡 Show loading skeleton on first load
if (isLoading && dailyTaskController.currentPage == 1) {
return SkeletonLoaders.dailyProgressReportSkeletonLoader();
}
// No data available
if (groupedTasks.isEmpty) {
return Center(
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()
..sort((a, b) => b.compareTo(a));
return MyCard.bordered(
borderRadiusAll: 10,
border: Border.all(color: Colors.grey.withOpacity(0.2)),
shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom),
paddingAll: 8,
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: sortedDates.length,
separatorBuilder: (_, __) => Column(
children: [
const SizedBox(height: 12),
Divider(color: Colors.grey.withOpacity(0.3), thickness: 1),
const SizedBox(height: 12),
],
),
itemBuilder: (context, dateIndex) {
final dateKey = sortedDates[dateIndex];
// 🔹 Auto expand if only one date present
if (sortedDates.length == 1 &&
!dailyTaskController.expandedDates.contains(sortedDates[0])) {
dailyTaskController.expandedDates.add(sortedDates[0]);
}
// 🧱 Return a scrollable column of cards
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...sortedDates.map((dateKey) {
final tasksForDate = groupedTasks[dateKey]!;
final date = DateTime.tryParse(dateKey);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => dailyTaskController.toggleDate(dateKey),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(
date != null
? DateFormat('dd MMM yyyy').format(date)
: dateKey,
fontWeight: 700,
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MyCard.bordered(
borderRadiusAll: 10,
border: Border.all(color: Colors.grey.withOpacity(0.2)),
shadow:
MyShadow(elevation: 1, position: MyShadowPosition.bottom),
paddingAll: 12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🗓 Date Header
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(
children: tasksForDate.asMap().entries.map((entry) {
final task = entry.value;
final index = entry.key;
// 🔽 Task List (expandable)
Obx(() {
if (!dailyTaskController.expandedDates
.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(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
children: tasksForDate.map((task) {
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 Padding(
padding: const EdgeInsets.only(bottom: 10),
child: MyContainer(
paddingAll: 12,
borderRadiusAll: 8,
border: Border.all(
color: Colors.grey.withOpacity(0.2)),
color: Colors.white,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🏗 Activity name & location
MyText.bodyMedium(activityName,
fontWeight: 600),
const SizedBox(height: 2),
MyText.bodySmall(location,
color: Colors.grey),
const SizedBox(height: 8),
// 👥 Team Members
GestureDetector(
onTap: () => _showTeamMembersBottomSheet(
task.teamMembers),
@ -408,13 +432,17 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
const Icon(Icons.group,
size: 18, color: Colors.blueAccent),
const SizedBox(width: 6),
MyText.bodyMedium('Team',
color: Colors.blueAccent,
fontWeight: 600),
MyText.bodyMedium(
'Team',
color: Colors.blueAccent,
fontWeight: 600,
),
],
),
),
const SizedBox(height: 8),
// 📊 Progress info
MyText.bodySmall(
"Completed: $completed / $planned",
fontWeight: 600,
@ -459,8 +487,12 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
: Colors.red[700],
),
const SizedBox(height: 12),
// 🎯 Action Buttons
SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(),
primary: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
@ -506,21 +538,24 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
],
),
),
),
if (index != tasksForDate.length - 1)
Divider(
color: Colors.grey.withOpacity(0.2),
thickness: 1,
height: 1),
],
);
}).toList(),
);
}).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/utils/permission_constants.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 {
DailyTaskPlanningScreen({super.key});
@ -29,23 +31,25 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final PermissionController permissionController =
Get.put(PermissionController());
final ProjectController projectController = Get.find<ProjectController>();
final ServiceController serviceController = Get.put(ServiceController());
@override
void initState() {
super.initState();
// Initial fetch if a project is already selected
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) {
dailyTaskPlanningController.fetchTaskData(projectId);
serviceController.fetchServices(projectId);
}
// Reactive fetch on project ID change
// Whenever project changes, fetch tasks & services
ever<String>(
projectController.selectedProjectId,
(newProjectId) {
if (newProjectId.isNotEmpty) {
dailyTaskPlanningController.fetchTaskData(newProjectId);
serviceController.fetchServices(newProjectId);
}
},
);
@ -119,18 +123,19 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) {
try {
await dailyTaskPlanningController.fetchTaskData(projectId);
await dailyTaskPlanningController.fetchTaskData(
projectId,
serviceId: serviceController.selectedService?.id,
);
} catch (e) {
debugPrint('Error refreshing task data: ${e.toString()}');
}
}
},
child: SingleChildScrollView(
physics:
const AlwaysScrollableScrollPhysics(), // <-- always allow drag
physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.x(0),
child: ConstrainedBox(
// <-- ensures full screen height
constraints: BoxConstraints(
minHeight: MediaQuery.of(context).size.height -
kToolbarHeight -
@ -143,6 +148,25 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
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),
Padding(
padding: MySpacing.x(8),
@ -161,7 +185,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
Widget dailyProgressReportTab() {
return Obx(() {
final isLoading = dailyTaskPlanningController.isLoading.value;
final isLoading = dailyTaskPlanningController.isFetchingTasks.value;
final dailyTasks = dailyTaskPlanningController.dailyTasks;
if (isLoading) {
@ -265,7 +289,6 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final validWorkAreas = floor.workAreas
.where((area) => area.workItems.isNotEmpty);
// For each valid work area, return a Floor+WorkArea ExpansionTile
return validWorkAreas.map((area) {
final floorWorkAreaKey =
"${buildingKey}_${floor.floorName}_${area.areaName}";
@ -279,6 +302,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final totalProgress = totalPlanned == 0
? 0.0
: (totalCompleted / totalPlanned).clamp(0.0, 1.0);
return ExpansionTile(
onExpansionChanged: (expanded) {
setMainState(() {
@ -330,7 +354,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
percent: totalProgress,
center: Text(
"${(totalProgress * 100).toStringAsFixed(0)}%",
style: TextStyle(
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
@ -416,7 +440,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
permissionController.hasPermission(
Permissions.assignReportTask))
IconButton(
icon: Icon(
icon: const Icon(
Icons.person_add_alt_1_rounded,
color:
Color.fromARGB(255, 46, 161, 233),
@ -480,7 +504,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
),
],
),
SizedBox(height: 4),
const SizedBox(height: 4),
MyText.bodySmall(
"${(progress * 100).toStringAsFixed(1)}%",
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;
}