Compare commits

...

17 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
61 changed files with 4253 additions and 2673 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -46,8 +46,9 @@ class DashboardController extends GetxController {
// Common ranges // Common ranges
final List<String> ranges = ['7D', '15D', '30D']; final List<String> ranges = ['7D', '15D', '30D'];
// Inject ProjectController // Inside your DashboardController
final ProjectController projectController = Get.find<ProjectController>(); final ProjectController projectController =
Get.put(ProjectController(), permanent: true);
@override @override
void onInit() { void onInit() {

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/project_model.dart'; import 'package:marco/model/project_model.dart';
import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart'; import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart';
import 'package:marco/model/dailyTaskPlanning/daily_progress_report_filter_response_model.dart';
class DailyTaskController extends GetxController { class DailyTaskController extends GetxController {
List<ProjectModel> projects = []; List<ProjectModel> projects = [];
@ -23,6 +24,12 @@ class DailyTaskController extends GetxController {
} }
} }
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 isLoading = true.obs;
RxBool isLoadingMore = false.obs; RxBool isLoadingMore = false.obs;
Map<String, List<TaskModel>> groupedDailyTasks = {}; Map<String, List<TaskModel>> groupedDailyTasks = {};
@ -51,9 +58,18 @@ class DailyTaskController extends GetxController {
); );
} }
void clearTaskFilters() {
selectedBuildings.clear();
selectedFloors.clear();
selectedActivities.clear();
selectedServices.clear();
startDateTask = null;
endDateTask = null;
update();
}
Future<void> fetchTaskData( Future<void> fetchTaskData(
String projectId, { String projectId, {
List<String>? serviceIds,
int pageNumber = 1, int pageNumber = 1,
int pageSize = 20, int pageSize = 20,
bool isLoadMore = false, bool isLoadMore = false,
@ -68,18 +84,25 @@ class DailyTaskController extends GetxController {
isLoadingMore.value = true; isLoadingMore.value = true;
} }
// Create the filter object
final filter = {
"buildingIds": selectedBuildings.toList(),
"floorIds": selectedFloors.toList(),
"activityIds": selectedActivities.toList(),
"serviceIds": selectedServices.toList(),
"dateFrom": startDateTask?.toIso8601String(),
"dateTo": endDateTask?.toIso8601String(),
};
final response = await ApiService.getDailyTasks( final response = await ApiService.getDailyTasks(
projectId, projectId,
dateFrom: startDateTask, filter: filter,
dateTo: endDateTask,
serviceIds: serviceIds,
pageNumber: pageNumber, pageNumber: pageNumber,
pageSize: pageSize, pageSize: pageSize,
); );
if (response != null && response.isNotEmpty) { if (response != null && response.isNotEmpty) {
for (var taskJson in response) { for (var task in response) {
final task = TaskModel.fromJson(taskJson);
final assignmentDateKey = final assignmentDateKey =
task.assignmentDate.toIso8601String().split('T')[0]; task.assignmentDate.toIso8601String().split('T')[0];
groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []).add(task); groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []).add(task);
@ -96,6 +119,35 @@ class DailyTaskController extends GetxController {
update(); update();
} }
FilterData? taskFilterData;
Future<void> fetchTaskFilter(String projectId) async {
isFilterLoading.value = true;
try {
final filterResponse = await ApiService.getDailyTaskFilter(projectId);
if (filterResponse != null && filterResponse.success) {
taskFilterData =
filterResponse.data; // now taskFilterData is FilterData?
logSafe(
"Task filter fetched successfully. Buildings: ${taskFilterData?.buildings.length}, Floors: ${taskFilterData?.floors.length}",
level: LogLevel.info,
);
} else {
logSafe(
"Failed to fetch task filter for projectId: $projectId",
level: LogLevel.warning,
);
}
} catch (e, stack) {
logSafe("Exception in fetchTaskFilter: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
} finally {
isFilterLoading.value = false;
update();
}
}
Future<void> selectDateRangeForTaskData( Future<void> selectDateRangeForTaskData(
BuildContext context, BuildContext context,
DailyTaskController controller, DailyTaskController controller,

View File

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

View File

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

View File

@ -4,12 +4,22 @@ import 'package:marco/helpers/services/tenant_service.dart';
import 'package:marco/model/tenant/tenant_list_model.dart'; import 'package:marco/model/tenant/tenant_list_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/controller/permission_controller.dart';
class TenantSelectionController extends GetxController { class TenantSelectionController extends GetxController {
final TenantService _tenantService = TenantService(); final TenantService _tenantService = TenantService();
var tenants = <Tenant>[].obs; // Tenant list
var isLoading = false.obs; 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 @override
void onInit() { void onInit() {
@ -17,83 +27,97 @@ class TenantSelectionController extends GetxController {
loadTenants(); loadTenants();
} }
/// Load tenants from API /// Load tenants and handle auto-selection
Future<void> loadTenants({bool fromTenantSelectionScreen = false}) async { Future<void> loadTenants() async {
isLoading.value = true;
isAutoSelecting.value = true; // show splash during auto-selection
try { try {
isLoading.value = true;
final data = await _tenantService.getTenants(); final data = await _tenantService.getTenants();
if (data != null) { if (data == null || data.isEmpty) {
tenants.value = data.map((e) => Tenant.fromJson(e)).toList();
final recentTenantId = LocalStorage.getRecentTenantId();
// If user came from TenantSelectionScreen & recent tenant exists, auto-select
if (fromTenantSelectionScreen && recentTenantId != null) {
final tenantExists = tenants.any((t) => t.id == recentTenantId);
if (tenantExists) {
await onTenantSelected(recentTenantId);
return;
} else {
// if tenant is no longer valid, clear recentTenant
await LocalStorage.removeRecentTenantId();
}
}
// Auto-select if only one tenant
if (tenants.length == 1) {
await onTenantSelected(tenants.first.id);
}
} else {
tenants.clear(); tenants.clear();
logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning); 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) { } catch (e, st) {
logSafe("❌ Exception in loadTenants", logSafe("❌ Exception in loadTenants",
level: LogLevel.error, error: e, stackTrace: st); level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar(
title: "Error",
message: "Failed to load organizations. Please try again.",
type: SnackbarType.error,
);
} finally { } finally {
isLoading.value = false; isLoading.value = false;
isAutoSelecting.value = false; // hide splash
} }
} }
/// Select tenant /// User manually selects a tenant
Future<void> onTenantSelected(String tenantId) async { 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 { try {
isLoading.value = true; isLoading.value = true;
final success = await _tenantService.selectTenant(tenantId); final success = await _tenantService.selectTenant(tenantId);
if (success) { if (!success) {
logSafe("✅ Tenant selection successful: $tenantId");
// Store selected tenant in memory
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
TenantService.setSelectedTenant(selectedTenant);
// 🔥 Save in LocalStorage
await LocalStorage.setRecentTenantId(tenantId);
// Navigate to dashboard
Get.offAllNamed('/dashboard');
showAppSnackbar(
title: "Success",
message: "Organization selected successfully.",
type: SnackbarType.success,
);
} else {
logSafe("❌ Tenant selection failed for: $tenantId",
level: LogLevel.warning);
// Show error snackbar
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Unable to select organization. Please try again.", message: "Unable to select organization. Please try again.",
type: SnackbarType.error, type: SnackbarType.error,
); );
return;
} }
} catch (e, st) {
logSafe("❌ Exception in onTenantSelected",
level: LogLevel.error, error: e, stackTrace: st);
// Show error snackbar for exception // 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( showAppSnackbar(
title: "Error", title: "Error",
message: "An unexpected error occurred while selecting organization.", message: "An unexpected error occurred while selecting organization.",
@ -103,4 +127,10 @@ class TenantSelectionController extends GetxController {
isLoading.value = false; 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

@ -22,6 +22,7 @@ class ApiEndpoints {
// Employee Screen API Endpoints // Employee Screen API Endpoints
static const String getAllEmployeesByProject = "/employee/list"; static const String getAllEmployeesByProject = "/employee/list";
static const String getAllEmployeesByOrganization = "/project/get/task/team";
static const String getAllEmployees = "/employee/list"; static const String getAllEmployees = "/employee/list";
static const String getEmployeesWithoutPermission = "/employee/basic"; static const String getEmployeesWithoutPermission = "/employee/basic";
static const String getRoles = "/roles/jobrole"; static const String getRoles = "/roles/jobrole";
@ -41,6 +42,7 @@ class ApiEndpoints {
static const String approveReportAction = "/task/approve"; static const String approveReportAction = "/task/approve";
static const String assignTask = "/project/task"; static const String assignTask = "/project/task";
static const String getmasterWorkCategories = "/Master/work-categories"; static const String getmasterWorkCategories = "/Master/work-categories";
static const String getDailyTaskProjectProgressFilter = "/task/filter";
////// Directory Module API Endpoints /////// ////// Directory Module API Endpoints ///////
static const String getDirectoryContacts = "/directory"; static const String getDirectoryContacts = "/directory";
@ -52,6 +54,8 @@ class ApiEndpoints {
static const String getDirectoryOrganization = "/directory/organization"; static const String getDirectoryOrganization = "/directory/organization";
static const String createContact = "/directory"; static const String createContact = "/directory";
static const String updateContact = "/directory"; static const String updateContact = "/directory";
static const String deleteContact = "/directory";
static const String restoreContact = "/directory/note";
static const String getDirectoryNotes = "/directory/notes"; static const String getDirectoryNotes = "/directory/notes";
static const String updateDirectoryNotes = "/directory/note"; static const String updateDirectoryNotes = "/directory/note";
static const String createBucket = "/directory/bucket"; static const String createBucket = "/directory/bucket";
@ -93,5 +97,7 @@ class ApiEndpoints {
static const String getAssignedOrganizations = static const String getAssignedOrganizations =
"/project/get/assigned/organization"; "/project/get/assigned/organization";
static const getAllOrganizations = "/organization/list";
static const String getAssignedServices = "/Project/get/assigned/services"; static const String getAssignedServices = "/Project/get/assigned/services";
} }

View File

@ -20,6 +20,9 @@ import 'package:marco/model/document/document_details_model.dart';
import 'package:marco/model/document/document_version_model.dart'; import 'package:marco/model/document/document_version_model.dart';
import 'package:marco/model/attendance/organization_per_project_list_model.dart'; import 'package:marco/model/attendance/organization_per_project_list_model.dart';
import 'package:marco/model/tenant/tenant_services_model.dart'; import 'package:marco/model/tenant/tenant_services_model.dart';
import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart';
import 'package:marco/model/dailyTaskPlanning/daily_progress_report_filter_response_model.dart';
import 'package:marco/model/all_organization_model.dart';
class ApiService { class ApiService {
static const bool enableLogs = true; static const bool enableLogs = true;
@ -319,6 +322,32 @@ class ApiService {
return null; 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 //// Get Services assigned to a Project
static Future<ServiceListResponse?> getAssignedServices( static Future<ServiceListResponse?> getAssignedServices(
String projectId) async { String projectId) async {
@ -354,14 +383,24 @@ class ApiService {
logSafe("Posting logs... count=${logs.length}"); logSafe("Posting logs... count=${logs.length}");
try { try {
final response = // Get token directly without triggering logout or refresh
await _postRequest(endpoint, logs, customTimeout: extendedTimeout); final token = await LocalStorage.getJwtToken();
if (token == null) {
if (response == null) { logSafe("No token available. Skipping logs post.",
logSafe("Post logs failed: null response", level: LogLevel.error); level: LogLevel.warning);
return false; return false;
} }
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint");
final headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
};
final response = await http
.post(uri, headers: headers, body: jsonEncode(logs))
.timeout(ApiService.extendedTimeout);
logSafe("Post logs response status: ${response.statusCode}"); logSafe("Post logs response status: ${response.statusCode}");
logSafe("Post logs response body: ${response.body}"); logSafe("Post logs response body: ${response.body}");
@ -1761,19 +1800,64 @@ class ApiService {
return false; return false;
} }
static Future<List<dynamic>?> getDirectoryComments( static Future<List<dynamic>?> getDirectoryComments(
String contactId, { String contactId, {
bool active = true, bool active = true,
}) async { }) async {
final url = "${ApiEndpoints.getDirectoryNotes}/$contactId?active=$active"; final url = "${ApiEndpoints.getDirectoryNotes}/$contactId?active=$active";
final response = await _getRequest(url); final response = await _getRequest(url);
final data = response != null final data = response != null
? _parseResponse(response, label: 'Directory Comments') ? _parseResponse(response, label: 'Directory Comments')
: null; : null;
return data is List ? data : null; return data is List ? data : null;
} }
/// Deletes a directory contact (sets active=false)
static Future<bool> deleteDirectoryContact(String contactId) async {
final endpoint = "${ApiEndpoints.updateContact}/$contactId/";
final queryParams = {'active': 'false'};
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint")
.replace(queryParameters: queryParams);
_log("Deleting directory contact at $uri");
final response = await _deleteRequest(
"$endpoint?active=false",
);
if (response != null && response.statusCode == 200) {
_log("Contact deleted successfully: ${response.body}");
return true;
}
_log("Failed to delete contact: ${response?.body}");
return false;
}
/// Restores a directory contact (sets active=true)
static Future<bool> restoreDirectoryContact(String contactId) async {
final endpoint = "${ApiEndpoints.updateContact}/$contactId/";
final queryParams = {'active': 'true'};
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint")
.replace(queryParameters: queryParams);
_log("Restoring directory contact at $uri");
final response = await _deleteRequest(
"$endpoint?active=true",
);
if (response != null && response.statusCode == 200) {
_log("Contact restored successfully: ${response.body}");
return true;
}
_log("Failed to restore contact: ${response?.body}");
return false;
}
static Future<bool> updateContact( static Future<bool> updateContact(
String contactId, Map<String, dynamic> payload) async { String contactId, Map<String, dynamic> payload) async {
@ -2045,6 +2129,36 @@ static Future<List<dynamic>?> getDirectoryComments(
); );
} }
/// 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( static Future<List<dynamic>?> getAllEmployees(
{String? organizationId}) async { {String? organizationId}) async {
var endpoint = ApiEndpoints.getAllEmployees; var endpoint = ApiEndpoints.getAllEmployees;
@ -2115,37 +2229,66 @@ static Future<List<dynamic>?> getDirectoryComments(
} }
// === Daily Task APIs === // === Daily Task APIs ===
/// Get Daily Task Project Report Filter
static Future<DailyProgressReportFilterResponse?> getDailyTaskFilter(
String projectId) async {
final endpoint =
"${ApiEndpoints.getDailyTaskProjectProgressFilter}/$projectId";
logSafe("Fetching daily task Progress filter for projectId: $projectId");
static Future<List<dynamic>?> getDailyTasks( try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Daily task filter request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(response,
label: "Daily Task Progress Filter");
if (jsonResponse != null) {
return DailyProgressReportFilterResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getDailyTask Progress Filter: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
static Future<List<TaskModel>?> getDailyTasks(
String projectId, { String projectId, {
DateTime? dateFrom, Map<String, dynamic>? filter,
DateTime? dateTo,
List<String>? serviceIds,
int pageNumber = 1, int pageNumber = 1,
int pageSize = 20, int pageSize = 20,
}) async { }) async {
final filterBody = { // Build query parameters
"serviceIds": serviceIds ?? [],
};
final query = { final query = {
"projectId": projectId, "projectId": projectId,
"pageNumber": pageNumber.toString(), "pageNumber": pageNumber.toString(),
"pageSize": pageSize.toString(), "pageSize": pageSize.toString(),
if (dateFrom != null) if (filter != null) "filter": jsonEncode(filter),
"dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom),
if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo),
"filter": jsonEncode(filterBody),
}; };
final uri = final uri =
Uri.parse(ApiEndpoints.getDailyTask).replace(queryParameters: query); Uri.parse(ApiEndpoints.getDailyTask).replace(queryParameters: query);
final response = await _getRequest(uri.toString()); final response = await _getRequest(uri.toString());
final parsed = response != null
return response != null
? _parseResponse(response, label: 'Daily Tasks') ? _parseResponse(response, label: 'Daily Tasks')
: null; : null;
if (parsed != null && parsed['data'] != null) {
return (parsed['data'] as List)
.map((e) => TaskModel.fromJson(e))
.toList();
}
return null;
} }
static Future<bool> reportTask({ static Future<bool> reportTask({
@ -2198,9 +2341,14 @@ static Future<List<dynamic>?> getDirectoryComments(
return response.statusCode == 200 && json['success'] == true; return response.statusCode == 200 && json['success'] == true;
} }
/// Fetch infra details for a given project /// Fetch infra details for a project, optionally filtered by service
static Future<Map<String, dynamic>?> getInfraDetails(String projectId) async { static Future<Map<String, dynamic>?> getInfraDetails(String projectId,
final endpoint = "/project/infra-details/$projectId"; {String? serviceId}) async {
String endpoint = "/project/infra-details/$projectId";
if (serviceId != null && serviceId.isNotEmpty) {
endpoint += "?serviceId=$serviceId";
}
final res = await _getRequest(endpoint); final res = await _getRequest(endpoint);
if (res == null) { if (res == null) {
@ -2213,10 +2361,14 @@ static Future<List<dynamic>?> getDirectoryComments(
as Map<String, dynamic>?; as Map<String, dynamic>?;
} }
/// Fetch work items for a given work area /// Fetch work items for a given work area, optionally filtered by service
static Future<Map<String, dynamic>?> getWorkItemsByWorkArea( static Future<Map<String, dynamic>?> getWorkItemsByWorkArea(String workAreaId,
String workAreaId) async { {String? serviceId}) async {
final endpoint = "/project/tasks/$workAreaId"; String endpoint = "/project/tasks/$workAreaId";
if (serviceId != null && serviceId.isNotEmpty) {
endpoint += "?serviceId=$serviceId";
}
final res = await _getRequest(endpoint); final res = await _getRequest(endpoint);
if (res == null) { if (res == null) {
@ -2235,12 +2387,16 @@ static Future<List<dynamic>?> getDirectoryComments(
required String description, required String description,
required List<String> taskTeam, required List<String> taskTeam,
DateTime? assignmentDate, DateTime? assignmentDate,
String? organizationId,
String? serviceId,
}) async { }) async {
final body = { final body = {
"workItemId": workItemId, "workItemId": workItemId,
"plannedTask": plannedTask, "plannedTask": plannedTask,
"description": description, "description": description,
"taskTeam": taskTeam, "taskTeam": taskTeam,
"organizationId": organizationId,
"serviceId": serviceId,
"assignmentDate": "assignmentDate":
(assignmentDate ?? DateTime.now()).toUtc().toIso8601String(), (assignmentDate ?? DateTime.now()).toUtc().toIso8601String(),
}; };

View File

@ -1,10 +1,6 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:url_strategy/url_strategy.dart'; import 'package:url_strategy/url_strategy.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:marco/helpers/services/auth_service.dart';
@ -26,7 +22,6 @@ Future<void> initializeApp() async {
await _setupDeviceInfo(); await _setupDeviceInfo();
await _handleAuthTokens(); await _handleAuthTokens();
await _setupTheme(); await _setupTheme();
await _setupControllers();
await _setupFirebaseMessaging(); await _setupFirebaseMessaging();
_finalizeAppStyle(); _finalizeAppStyle();
@ -43,6 +38,19 @@ Future<void> initializeApp() async {
} }
} }
Future<void> _handleAuthTokens() async {
final refreshToken = await LocalStorage.getRefreshToken();
if (refreshToken?.isNotEmpty ?? false) {
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
final success = await AuthService.refreshToken();
if (!success) {
logSafe("⚠️ Refresh token invalid or expired. User must login again.");
}
} else {
logSafe("❌ No refresh token found. Skipping refresh.");
}
}
Future<void> _setupUI() async { Future<void> _setupUI() async {
setPathUrlStrategy(); setPathUrlStrategy();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@ -69,50 +77,11 @@ Future<void> _setupDeviceInfo() async {
logSafe("📱 Device Info: ${deviceInfoService.deviceData}"); logSafe("📱 Device Info: ${deviceInfoService.deviceData}");
} }
Future<void> _handleAuthTokens() async {
final refreshToken = await LocalStorage.getRefreshToken();
if (refreshToken?.isNotEmpty ?? false) {
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
final success = await AuthService.refreshToken();
if (!success) {
logSafe(
"⚠️ Refresh token invalid or expired. Skipping controller injection.");
}
} else {
logSafe("❌ No refresh token found. Skipping refresh.");
}
}
Future<void> _setupTheme() async { Future<void> _setupTheme() async {
await ThemeCustomizer.init(); await ThemeCustomizer.init();
logSafe("💡 Theme customizer initialized."); logSafe("💡 Theme customizer initialized.");
} }
Future<void> _setupControllers() async {
final token = LocalStorage.getString('jwt_token');
if (token?.isEmpty ?? true) {
logSafe("⚠️ No valid JWT token found. Skipping controller initialization.");
return;
}
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
logSafe("💡 PermissionController injected.");
}
if (!Get.isRegistered<ProjectController>()) {
Get.put(ProjectController(), permanent: true);
logSafe("💡 ProjectController injected as permanent.");
}
await Future.wait([
Get.find<PermissionController>().loadData(token!),
Get.find<ProjectController>().fetchProjects(),
]);
}
// Commented out Firebase Messaging setup
Future<void> _setupFirebaseMessaging() async { Future<void> _setupFirebaseMessaging() async {
await FirebaseNotificationService().initialize(); await FirebaseNotificationService().initialize();
logSafe("💡 Firebase Messaging initialized."); logSafe("💡 Firebase Messaging initialized.");

View File

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

View File

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

View File

@ -129,6 +129,17 @@ class TenantService implements ITenantService {
logSafe("⚠️ ProjectController not found while refreshing projects"); 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; return true;
} }

View File

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

View File

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

View File

@ -197,13 +197,13 @@ class ProjectProgressChart extends StatelessWidget {
height: height > 280 ? 280 : height, height: height > 280 ? 280 : height,
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blueGrey.shade50, // Remove background
color: Colors.transparent,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
child: SfCartesianChart( child: SfCartesianChart(
tooltipBehavior: TooltipBehavior(enable: true), tooltipBehavior: TooltipBehavior(enable: true),
legend: Legend(isVisible: true, position: LegendPosition.bottom), legend: Legend(isVisible: true, position: LegendPosition.bottom),
// Use CategoryAxis so only nonZeroData dates show up
primaryXAxis: CategoryAxis( primaryXAxis: CategoryAxis(
majorGridLines: const MajorGridLines(width: 0), majorGridLines: const MajorGridLines(width: 0),
axisLine: const AxisLine(width: 0), axisLine: const AxisLine(width: 0),
@ -273,48 +273,44 @@ class ProjectProgressChart extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
color: Colors.grey.shade50, color: Colors.transparent,
), ),
child: LayoutBuilder( child: Scrollbar(
builder: (context, constraints) { thumbVisibility: true,
return SingleChildScrollView( trackVisibility: true,
scrollDirection: Axis.horizontal, child: SingleChildScrollView(
child: ConstrainedBox( scrollDirection: Axis.horizontal,
constraints: BoxConstraints(minWidth: constraints.maxWidth), child: ConstrainedBox(
child: SingleChildScrollView( constraints: BoxConstraints(minWidth: screenWidth),
scrollDirection: Axis.vertical, child: SingleChildScrollView(
child: DataTable( scrollDirection: Axis.vertical,
columnSpacing: screenWidth < 600 ? 16 : 36, child: DataTable(
headingRowHeight: 44, columnSpacing: screenWidth < 600 ? 16 : 36,
headingRowColor: MaterialStateProperty.all( headingRowHeight: 44,
Colors.blueAccent.withOpacity(0.08)), headingRowColor: MaterialStateProperty.all(
headingTextStyle: const TextStyle( Colors.blueAccent.withOpacity(0.08)),
fontWeight: FontWeight.bold, color: Colors.black87), headingTextStyle: const TextStyle(
columns: const [ fontWeight: FontWeight.bold, color: Colors.black87),
DataColumn(label: Text('Date')), columns: const [
DataColumn(label: Text('Planned')), DataColumn(label: Text('Date')),
DataColumn(label: Text('Completed')), DataColumn(label: Text('Planned')),
], DataColumn(label: Text('Completed')),
rows: nonZeroData.map((task) { ],
return DataRow( rows: nonZeroData.map((task) {
cells: [ return DataRow(
DataCell(Text(DateFormat('d MMM').format(task.date))), cells: [
DataCell(Text( DataCell(Text(DateFormat('d MMM').format(task.date))),
'${task.planned}', DataCell(Text('${task.planned}',
style: TextStyle(color: _getTaskColor('Planned')), style: TextStyle(color: _getTaskColor('Planned')))),
)), DataCell(Text('${task.completed}',
DataCell(Text( style: TextStyle(color: _getTaskColor('Completed')))),
'${task.completed}', ],
style: TextStyle(color: _getTaskColor('Completed')), );
)), }).toList(),
],
);
}).toList(),
),
), ),
), ),
); ),
}, ),
), ),
); );
} }
@ -323,7 +319,7 @@ class ProjectProgressChart extends StatelessWidget {
return Container( return Container(
height: height > 280 ? 280 : height, height: height > 280 ? 280 : height,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blueGrey.shade50, color: Colors.transparent,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
child: const Center( child: const Center(

View File

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

View File

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

View File

@ -77,7 +77,32 @@ class OrganizationSelector extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
if (controller.isLoadingOrganizations.value) { if (controller.isLoadingOrganizations.value) {
return const Center(child: CircularProgressIndicator()); 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) { } else if (controller.organizations.isEmpty) {
return Center( return Center(
child: Padding( child: Padding(
@ -96,7 +121,6 @@ class OrganizationSelector extends StatelessWidget {
...controller.organizations.map((e) => e.name) ...controller.organizations.map((e) => e.name)
]; ];
// Listen to selectedOrganization.value
return _popupSelector( return _popupSelector(
currentValue: controller.currentSelection, currentValue: controller.currentSelection,
items: orgNames, items: orgNames,

View File

@ -88,11 +88,40 @@ class ServiceSelector extends StatelessWidget {
); );
} }
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
if (controller.isLoadingServices.value) { if (controller.isLoadingServices.value) {
return const Center(child: CircularProgressIndicator()); return _skeletonSelector();
} }
final serviceNames = controller.services.isEmpty final serviceNames = controller.services.isEmpty

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

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

View File

@ -39,8 +39,7 @@ class _AttendanceFilterBottomSheetState
final endDate = widget.controller.endDateAttendance; final endDate = widget.controller.endDateAttendance;
if (startDate != null && endDate != null) { if (startDate != null && endDate != null) {
final start = final start = DateTimeUtils.formatDate(startDate, 'dd MMM yyyy');
DateTimeUtils.formatDate(startDate, 'dd MMM yyyy');
final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy'); final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy');
return "$start - $end"; return "$start - $end";
} }
@ -161,7 +160,32 @@ class _AttendanceFilterBottomSheetState
), ),
Obx(() { Obx(() {
if (widget.controller.isLoadingOrganizations.value) { if (widget.controller.isLoadingOrganizations.value) {
return const Center(child: CircularProgressIndicator()); 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) { } else if (widget.controller.organizations.isEmpty) {
return Center( return Center(
child: Padding( child: Padding(

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

View File

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

View File

@ -1,82 +1,289 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:get/get.dart';
import 'package:marco/controller/task_planning/daily_task_controller.dart'; import 'package:marco/controller/task_planning/daily_task_controller.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
class DailyProgressReportFilter extends StatelessWidget { class DailyTaskFilterBottomSheet extends StatelessWidget {
final DailyTaskController controller; final DailyTaskController controller;
final PermissionController permissionController;
const DailyProgressReportFilter({ const DailyTaskFilterBottomSheet({super.key, required this.controller});
super.key,
required this.controller,
required this.permissionController,
});
String getLabelText() {
final startDate = controller.startDateTask;
final endDate = controller.endDateTask;
if (startDate != null && endDate != null) {
final start = DateFormat('dd MM yyyy').format(startDate);
final end = DateFormat('dd MM yyyy').format(endDate);
return "$start - $end";
}
return "Select Date Range";
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final filterData = controller.taskFilterData;
if (filterData == null) return const SizedBox.shrink();
final hasFilters = [
filterData.buildings,
filterData.floors,
filterData.activities,
filterData.services,
].any((list) => list.isNotEmpty);
return BaseBottomSheet( return BaseBottomSheet(
title: "Filter Tasks", title: "Filter Tasks",
onCancel: () => Navigator.pop(context), submitText: "Apply",
showButtons: hasFilters,
onCancel: () => Get.back(),
onSubmit: () { onSubmit: () {
Navigator.pop(context, { if (controller.selectedProjectId != null) {
'startDate': controller.startDateTask, controller.fetchTaskData(
'endDate': controller.endDateTask, controller.selectedProjectId!,
}); );
}
Get.back();
}, },
child: Column( child: SingleChildScrollView(
crossAxisAlignment: CrossAxisAlignment.start, child: hasFilters
children: [ ? Column(
MyText.titleSmall("Select Date Range", fontWeight: 600), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: 8),
InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => controller.selectDateRangeForTaskData(
context,
controller,
),
child: Ink(
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [ children: [
Icon(Icons.date_range, color: Colors.blue.shade600), Align(
const SizedBox(width: 12), alignment: Alignment.centerRight,
Expanded( child: TextButton(
child: Text( onPressed: () {
getLabelText(), controller.clearTaskFilters();
style: const TextStyle( },
fontSize: 16, child: MyText(
color: Colors.black87, "Reset Filter",
fontWeight: FontWeight.w500, style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.w600,
),
), ),
overflow: TextOverflow.ellipsis,
), ),
), ),
const Icon(Icons.arrow_drop_down, color: Colors.grey), MySpacing.height(8),
_multiSelectField(
label: "Buildings",
items: filterData.buildings,
fallback: "Select Buildings",
selectedValues: controller.selectedBuildings,
),
_multiSelectField(
label: "Floors",
items: filterData.floors,
fallback: "Select Floors",
selectedValues: controller.selectedFloors,
),
_multiSelectField(
label: "Activities",
items: filterData.activities,
fallback: "Select Activities",
selectedValues: controller.selectedActivities,
),
_multiSelectField(
label: "Services",
items: filterData.services,
fallback: "Select Services",
selectedValues: controller.selectedServices,
),
MySpacing.height(8),
_dateRangeSelector(context),
], ],
)
: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: MyText(
"No filters available",
style: const TextStyle(color: Colors.grey),
),
),
),
),
);
}
Widget _multiSelectField({
required String label,
required List<dynamic> items,
required String fallback,
required RxSet<String> selectedValues,
}) {
if (items.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
MySpacing.height(8),
Obx(() {
final selectedNames = items
.where((item) => selectedValues.contains(item.id))
.map((item) => item.name)
.join(", ");
final displayText =
selectedNames.isNotEmpty ? selectedNames : fallback;
return Builder(
builder: (context) {
return GestureDetector(
onTap: () async {
final RenderBox button =
context.findRenderObject() as RenderBox;
final RenderBox overlay = Overlay.of(context)
.context
.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero);
await showMenu(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy + button.size.height,
overlay.size.width - position.dx - button.size.width,
0,
),
items: items.map((item) {
return PopupMenuItem<String>(
enabled: false,
child: StatefulBuilder(
builder: (context, setState) {
final isChecked = selectedValues.contains(item.id);
return CheckboxListTile(
dense: true,
value: isChecked,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: MyText(item.name),
// --- Styles to match Document Filter ---
checkColor: Colors.white,
side: const BorderSide(
color: Colors.black, width: 1.5),
fillColor:
MaterialStateProperty.resolveWith<Color>(
(states) {
if (states.contains(MaterialState.selected)) {
return Colors.indigo;
}
return Colors.white;
},
),
onChanged: (val) {
if (val == true) {
selectedValues.add(item.id);
} else {
selectedValues.remove(item.id);
}
setState(() {});
},
);
},
),
);
}).toList(),
);
},
child: Container(
padding: MySpacing.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: MyText(
displayText,
style: const TextStyle(color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
);
},
);
}),
MySpacing.height(16),
],
);
}
Widget _dateRangeSelector(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium("Select Date Range"),
MySpacing.height(8),
Row(
children: [
Expanded(
child: _dateButton(
label: controller.startDateTask != null
? "${controller.startDateTask!.day}/${controller.startDateTask!.month}/${controller.startDateTask!.year}"
: "From Date",
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: controller.startDateTask ?? DateTime.now(),
firstDate: DateTime(2022),
lastDate: DateTime.now(),
);
if (picked != null) {
controller.startDateTask = picked;
controller.update(); // rebuild widget
}
},
), ),
), ),
), MySpacing.width(12),
], Expanded(
child: _dateButton(
label: controller.endDateTask != null
? "${controller.endDateTask!.day}/${controller.endDateTask!.month}/${controller.endDateTask!.year}"
: "To Date",
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: controller.endDateTask ?? DateTime.now(),
firstDate: DateTime(2022),
lastDate: DateTime.now(),
);
if (picked != null) {
controller.endDateTask = picked;
controller.update();
}
},
),
),
],
),
MySpacing.height(16),
],
);
}
Widget _dateButton({required String label, required VoidCallback onTap}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
const SizedBox(width: 8),
Expanded(
child: MyText(label),
),
],
),
), ),
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -194,8 +194,11 @@ class _DocumentUploadBottomSheetState extends State<DocumentUploadBottomSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sheetTitle = widget.isEmployee
? "Upload Employee Document"
: "Upload Project Document";
return BaseBottomSheet( return BaseBottomSheet(
title: "Upload Document", title: sheetTitle,
onCancel: () => Navigator.pop(context), onCancel: () => Navigator.pop(context),
onSubmit: _handleSubmit, onSubmit: _handleSubmit,
child: Form( child: Form(

View File

@ -5,7 +5,7 @@ import 'package:intl/intl.dart';
import 'package:marco/controller/employee/add_employee_controller.dart'; import 'package:marco/controller/employee/add_employee_controller.dart';
import 'package:marco/controller/employee/employees_screen_controller.dart'; import 'package:marco/controller/employee/employees_screen_controller.dart';
import 'package:marco/controller/tenant/organization_selection_controller.dart'; import 'package:marco/controller/tenant/all_organization_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
@ -24,8 +24,7 @@ class AddEmployeeBottomSheet extends StatefulWidget {
class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
with UIMixin { with UIMixin {
late final AddEmployeeController _controller; late final AddEmployeeController _controller;
final OrganizationController _organizationController = late final AllOrganizationController _organizationController;
Get.put(OrganizationController());
// Local UI state // Local UI state
bool _hasApplicationAccess = false; bool _hasApplicationAccess = false;
@ -40,50 +39,75 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
void initState() { void initState() {
super.initState(); super.initState();
_controller = Get.put( // Initialize text controllers
AddEmployeeController(), _orgFieldController = TextEditingController();
// Unique tag to avoid clashes, but stable for this widget instance _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(), tag: UniqueKey().toString(),
); );
_orgFieldController = TextEditingController(text: ''); // Keep _orgFieldController in sync with selected organization safely
_joiningDateController = TextEditingController(text: ''); ever(_organizationController.selectedOrganization, (_) {
_genderController = TextEditingController(text: ''); WidgetsBinding.instance.addPostFrameCallback((_) {
_roleController = TextEditingController(text: ''); _orgFieldController.text =
_organizationController.selectedOrganization.value?.name ??
'All Organizations';
});
});
// Prefill when editing // Prefill other fields if editing
if (widget.employeeData != null) { if (widget.employeeData != null) {
_controller.editingEmployeeData = widget.employeeData; _controller.editingEmployeeData = widget.employeeData;
_controller.prefillFields(); _controller.prefillFields();
final orgId = widget.employeeData!['organizationId']; // Application access
if (orgId != null) { _hasApplicationAccess =
_controller.selectedOrganizationId = orgId; widget.employeeData?['hasApplicationAccess'] ?? false;
final selectedOrg = _organizationController.organizations // Email
.firstWhereOrNull((o) => o.id == orgId); final email = widget.employeeData?['email'];
if (selectedOrg != null) { if (email != null && email.toString().isNotEmpty) {
_organizationController.selectOrganization(selectedOrg); _controller.basicValidator.getController('email')?.text =
_orgFieldController.text = selectedOrg.name; email.toString();
}
} }
// Joining date
if (_controller.joiningDate != null) { if (_controller.joiningDate != null) {
_joiningDateController.text = _joiningDateController.text =
DateFormat('dd MMM yyyy').format(_controller.joiningDate!); DateFormat('dd MMM yyyy').format(_controller.joiningDate!);
} }
// Gender
if (_controller.selectedGender != null) { if (_controller.selectedGender != null) {
_genderController.text = _genderController.text =
_controller.selectedGender!.name.capitalizeFirst ?? ''; _controller.selectedGender!.name.capitalizeFirst ?? '';
} }
final roleName = _controller.roles.firstWhereOrNull( // Prefill Role
(r) => r['id'] == _controller.selectedRoleId)?['name'] ?? _controller.fetchRoles().then((_) {
''; if (_controller.selectedRoleId != null) {
_roleController.text = roleName; final roleName = _controller.roles.firstWhereOrNull(
(r) => r['id'] == _controller.selectedRoleId,
)?['name'];
if (roleName != null) {
_roleController.text = roleName;
}
_controller.update();
}
});
} else { } else {
_orgFieldController.text = _organizationController.currentSelection; // Not editing: fetch roles
_controller.fetchRoles();
} }
} }
@ -137,27 +161,37 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
MySpacing.height(16), MySpacing.height(16),
_sectionLabel('Organization'), _sectionLabel('Organization'),
MySpacing.height(8), MySpacing.height(8),
GestureDetector( Obx(() {
onTap: () => _showOrganizationPopup(context), return GestureDetector(
child: AbsorbPointer( onTap: () => _showOrganizationPopup(context),
child: TextFormField( child: AbsorbPointer(
readOnly: true, child: TextFormField(
controller: _orgFieldController, readOnly: true,
validator: (val) { controller: _orgFieldController,
if (val == null || validator: (val) {
val.trim().isEmpty || if (val == null ||
val == 'All Organizations') { val.trim().isEmpty ||
return 'Organization is required'; val == 'All Organizations') {
} return 'Organization is required';
return null; }
}, return null;
decoration: },
_inputDecoration('Select Organization').copyWith( decoration:
suffixIcon: const Icon(Icons.expand_more), _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), MySpacing.height(24),
_sectionLabel('Application Access'), _sectionLabel('Application Access'),
Row( Row(
@ -333,8 +367,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
return null; return null;
}, },
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
decoration: _inputDecoration('e.g., john.doe@example.com').copyWith( decoration: _inputDecoration('e.g., john.doe@example.com').copyWith(),
),
), ),
], ],
); );
@ -466,7 +499,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
context: context, context: context,
initialDate: _controller.joiningDate ?? DateTime.now(), initialDate: _controller.joiningDate ?? DateTime.now(),
firstDate: DateTime(2000), firstDate: DateTime(2000),
lastDate: DateTime(2100), lastDate: DateTime.now(),
); );
if (picked != null) { if (picked != null) {
@ -480,12 +513,13 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
final isValid = final isValid =
_controller.basicValidator.formKey.currentState?.validate() ?? false; _controller.basicValidator.formKey.currentState?.validate() ?? false;
final selectedOrg = _organizationController.selectedOrganization.value;
if (!isValid || if (!isValid ||
_controller.joiningDate == null || _controller.joiningDate == null ||
_controller.selectedGender == null || _controller.selectedGender == null ||
_controller.selectedRoleId == null || _controller.selectedRoleId == null ||
_organizationController.currentSelection.isEmpty || selectedOrg == null) {
_organizationController.currentSelection == 'All Organizations') {
showAppSnackbar( showAppSnackbar(
title: 'Missing Fields', title: 'Missing Fields',
message: 'Please complete all required fields.', message: 'Please complete all required fields.',
@ -494,6 +528,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
return; return;
} }
_controller.selectedOrganizationId = selectedOrg.id;
final result = await _controller.createOrUpdateEmployee( final result = await _controller.createOrUpdateEmployee(
email: _controller.basicValidator.getController('email')?.text.trim(), email: _controller.basicValidator.getController('email')?.text.trim(),
hasApplicationAccess: _hasApplicationAccess, hasApplicationAccess: _hasApplicationAccess,
@ -526,7 +562,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
return; return;
} }
final selected = await showMenu<String>( final selectedOrgId = await showMenu<String>(
context: context, context: context,
position: _popupMenuPosition(context), position: _popupMenuPosition(context),
items: orgs items: orgs
@ -539,12 +575,10 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
.toList(), .toList(),
); );
if (selected != null && selected.trim().isNotEmpty) { if (selectedOrgId != null) {
final chosen = orgs.firstWhere((e) => e.id == selected); final chosenOrg = orgs.firstWhere((org) => org.id == selectedOrgId,
_organizationController.selectOrganization(chosen); orElse: () => orgs.first);
_controller.selectedOrganizationId = chosen.id; _organizationController.selectOrganization(chosenOrg);
_orgFieldController.text = chosen.name;
_controller.update();
} }
} }

View File

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

View File

@ -12,7 +12,7 @@ import 'package:marco/view/error_pages/error_500_screen.dart';
import 'package:marco/view/dashboard/dashboard_screen.dart'; import 'package:marco/view/dashboard/dashboard_screen.dart';
import 'package:marco/view/Attendence/attendance_screen.dart'; import 'package:marco/view/Attendence/attendance_screen.dart';
import 'package:marco/view/taskPlanning/daily_task_planning.dart'; import 'package:marco/view/taskPlanning/daily_task_planning.dart';
import 'package:marco/view/taskPlanning/daily_progress.dart'; import 'package:marco/view/taskPlanning/daily_progress_report.dart';
import 'package:marco/view/employees/employees_screen.dart'; import 'package:marco/view/employees/employees_screen.dart';
import 'package:marco/view/auth/login_option_screen.dart'; import 'package:marco/view/auth/login_option_screen.dart';
import 'package:marco/view/auth/mpin_screen.dart'; import 'package:marco/view/auth/mpin_screen.dart';
@ -45,7 +45,7 @@ getPageRoute() {
page: () => DashboardScreen(), page: () => DashboardScreen(),
middlewares: [AuthMiddleware()]), middlewares: [AuthMiddleware()]),
GetPage( GetPage(
name: '/home', name: '/dashboard',
page: () => DashboardScreen(), // or your actual home screen page: () => DashboardScreen(), // or your actual home screen
middlewares: [AuthMiddleware()], middlewares: [AuthMiddleware()],
), ),

View File

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

View File

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

View File

@ -84,8 +84,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
/// Project Progress Chart Section /// Project Progress Chart Section
Widget _buildProjectProgressChartSection() { Widget _buildProjectProgressChartSection() {
return Obx(() { return Obx(() {
if (dashboardController.projectChartData.isEmpty) { if (dashboardController.projectChartData.isEmpty) {
return const Padding( return const Padding(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
@ -110,7 +108,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
/// Attendance Chart Section /// Attendance Chart Section
Widget _buildAttendanceChartSection() { Widget _buildAttendanceChartSection() {
return Obx(() { return Obx(() {
final isAttendanceAllowed = menuController.isMenuAllowed("Attendance"); final isAttendanceAllowed = menuController.isMenuAllowed("Attendance");
if (!isAttendanceAllowed) { if (!isAttendanceAllowed) {
@ -212,7 +209,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
return _buildLoadingSkeleton(context); return _buildLoadingSkeleton(context);
} }
if (menuController.hasError.value && menuController.menuItems.isEmpty) { if (menuController.hasError.value || menuController.menuItems.isEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Center( child: Center(
@ -224,6 +221,10 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
); );
} }
final projectController = Get.find<ProjectController>();
final isProjectSelected = projectController.selectedProject != null;
// Keep previous stat items (icons, title, routes)
final stats = [ final stats = [
_StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success, _StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success,
DashboardScreen.attendanceRoute), DashboardScreen.attendanceRoute),
@ -241,8 +242,16 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
DashboardScreen.documentMainPageRoute), DashboardScreen.documentMainPageRoute),
]; ];
final projectController = Get.find<ProjectController>(); // Safe menu check function to avoid exceptions
final isProjectSelected = projectController.selectedProject != null; bool _isMenuAllowed(String menuTitle) {
try {
return menuController.menuItems.isNotEmpty
? menuController.isMenuAllowed(menuTitle)
: false;
} catch (e) {
return false;
}
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -250,7 +259,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
if (!isProjectSelected) _buildNoProjectMessage(), if (!isProjectSelected) _buildNoProjectMessage(),
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// smaller width cards fit more in a row
int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 8); int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 8);
double cardWidth = double cardWidth =
(constraints.maxWidth - (crossAxisCount - 1) * 6) / (constraints.maxWidth - (crossAxisCount - 1) * 6) /
@ -261,14 +269,10 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
runSpacing: 6, runSpacing: 6,
alignment: WrapAlignment.start, alignment: WrapAlignment.start,
children: stats children: stats
.where((stat) { .where((stat) => _isMenuAllowed(stat.title))
if (stat.title == "Documents") return true;
return menuController.isMenuAllowed(stat.title);
})
.map((stat) => .map((stat) =>
_buildStatCard(stat, isProjectSelected, cardWidth)) _buildStatCard(stat, isProjectSelected, cardWidth))
.toList() .toList(),
.cast<Widget>(),
); );
}, },
), ),

View File

@ -17,6 +17,7 @@ import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/model/directory/add_contact_bottom_sheet.dart'; import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/model/directory/directory_comment_model.dart';
// HELPER: Delta to HTML conversion // HELPER: Delta to HTML conversion
String _convertDeltaToHtml(dynamic delta) { String _convertDeltaToHtml(dynamic delta) {
@ -344,49 +345,34 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
Widget _buildCommentsTab() { Widget _buildCommentsTab() {
return Obx(() { return Obx(() {
final contactId = contactRx.value.id; final contactId = contactRx.value.id;
final comments = directoryController.combinedComments(contactId);
// Get active and inactive comments
final activeComments = directoryController
.getCommentsForContact(contactId)
.where((c) => c.isActive)
.toList();
final inactiveComments = directoryController
.getCommentsForContact(contactId)
.where((c) => !c.isActive)
.toList();
// Combine both and keep the same sorting (recent first)
final comments =
[...activeComments, ...inactiveComments].reversed.toList();
final editingId = directoryController.editingCommentId.value; final editingId = directoryController.editingCommentId.value;
if (comments.isEmpty) {
return Center(
child: MyText.bodyLarge("No notes yet.", color: Colors.grey),
);
}
return Stack( return Stack(
children: [ children: [
MyRefreshIndicator( comments.isEmpty
onRefresh: () async { ? Center(
await directoryController.fetchCommentsForContact(contactId, child: MyText.bodyLarge("No notes yet.", color: Colors.grey),
active: true); )
await directoryController.fetchCommentsForContact(contactId, : MyRefreshIndicator(
active: false); onRefresh: () async {
}, await directoryController.fetchCommentsForContact(contactId,
child: Padding( active: true);
padding: MySpacing.xy(12, 12), await directoryController.fetchCommentsForContact(contactId,
child: ListView.separated( active: false);
physics: const AlwaysScrollableScrollPhysics(), },
padding: const EdgeInsets.only(bottom: 100), child: Padding(
itemCount: comments.length, padding: MySpacing.xy(12, 12),
separatorBuilder: (_, __) => MySpacing.height(14), child: ListView.separated(
itemBuilder: (_, index) => physics: const AlwaysScrollableScrollPhysics(),
_buildCommentItem(comments[index], editingId, contactId), padding: const EdgeInsets.only(bottom: 100),
), itemCount: comments.length,
), separatorBuilder: (_, __) => MySpacing.height(14),
), itemBuilder: (_, index) => _buildCommentItem(
comments[index], editingId, contactId),
),
),
),
if (editingId == null) if (editingId == null)
Positioned( Positioned(
bottom: 20, bottom: 20,
@ -415,7 +401,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
}); });
} }
Widget _buildCommentItem(comment, editingId, contactId) { Widget _buildCommentItem(
DirectoryComment comment, String? editingId, String contactId) {
final isEditing = editingId == comment.id; final isEditing = editingId == comment.id;
final initials = comment.createdBy.firstName.isNotEmpty final initials = comment.createdBy.firstName.isNotEmpty
? comment.createdBy.firstName[0].toUpperCase() ? comment.createdBy.firstName[0].toUpperCase()
@ -429,180 +416,190 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
) )
: null; : null;
final isInactive = !comment.isActive;
return Container( return Container(
margin: const EdgeInsets.symmetric(vertical: 6), margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.grey.shade200), border: Border.all(color: Colors.grey.shade200),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.03), color: Colors.black.withOpacity(0.03),
blurRadius: 6, blurRadius: 6,
offset: const Offset(0, 2), offset: const Offset(0, 2),
), ),
], ],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 🧑 Header // 🧑 Header
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Avatar( Avatar(
firstName: initials, firstName: initials,
lastName: '', lastName: '',
size: 40, size: 40,
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Full name on top
Text(
"${comment.createdBy.firstName} ${comment.createdBy.lastName}",
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 15,
color: Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
// Job Role
if (comment.createdBy.jobRoleName?.isNotEmpty ?? false)
Text( Text(
comment.createdBy.jobRoleName, "${comment.createdBy.firstName} ${comment.createdBy.lastName}",
style: TextStyle( style: TextStyle(
fontSize: 13, fontWeight: FontWeight.w700,
color: Colors.indigo[600], fontSize: 15,
fontWeight: FontWeight.w500, color: isInactive ? Colors.grey : Colors.black87,
fontStyle:
isInactive ? FontStyle.italic : FontStyle.normal,
),
overflow: TextOverflow.ellipsis,
),
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,
), ),
), ),
const SizedBox(height: 2), ],
// Timestamp ),
Text( ),
DateTimeUtils.convertUtcToLocal(
comment.createdAt.toString(), // Action buttons
format: 'dd MMM yyyy, hh:mm a', 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;
},
), ),
style: TextStyle( IconButton(
fontSize: 12, icon: const Icon(Icons.delete_outline,
color: Colors.grey[600], 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,
// Action buttons size: 18, color: Colors.green),
Row( tooltip: "Restore",
mainAxisSize: MainAxisSize.min, splashRadius: 18,
children: [ onPressed: () async {
if (!comment.isActive) await Get.dialog(
IconButton( ConfirmDialog(
icon: const Icon(Icons.restore, title: "Restore Note",
size: 18, color: Colors.green), message:
tooltip: "Restore", "Are you sure you want to restore this note?",
splashRadius: 18, confirmText: "Restore",
onPressed: () async { confirmColor: Colors.green,
await Get.dialog( icon: Icons.restore,
ConfirmDialog( onConfirm: () async {
title: "Restore Note", await directoryController.restoreComment(
message: comment.id, contactId);
"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);
},
),
);
},
),
if (comment.isActive) ...[
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);
},
),
);
},
),
],
],
),
],
),
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);
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: Colors.black87,
),
"p": html.Style(
margin: html.Margins.only(bottom: 6),
lineHeight: const html.LineHeight(1.4),
),
"strong": html.Style(
fontWeight: FontWeight.w700,
color: Colors.black87,
),
},
), ),
],
), 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,
),
},
),
],
));
} }
Widget _iconInfoRow( Widget _iconInfoRow(

View File

@ -17,6 +17,7 @@ import 'package:marco/view/directory/contact_detail_screen.dart';
import 'package:marco/view/directory/manage_bucket_screen.dart'; import 'package:marco/view/directory/manage_bucket_screen.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
class DirectoryView extends StatefulWidget { class DirectoryView extends StatefulWidget {
@override @override
@ -89,7 +90,7 @@ class _DirectoryViewState extends State<DirectoryView> {
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(5),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -114,7 +115,7 @@ class _DirectoryViewState extends State<DirectoryView> {
backgroundColor: Colors.grey[300], backgroundColor: Colors.grey[300],
foregroundColor: Colors.black, foregroundColor: Colors.black,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
), ),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
), ),
@ -129,7 +130,7 @@ class _DirectoryViewState extends State<DirectoryView> {
backgroundColor: Colors.indigo, backgroundColor: Colors.indigo,
foregroundColor: Colors.white, foregroundColor: Colors.white,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
), ),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
), ),
@ -179,6 +180,7 @@ class _DirectoryViewState extends State<DirectoryView> {
), ),
body: Column( body: Column(
children: [ children: [
// Search + Filter + More menu
Padding( Padding(
padding: MySpacing.xy(8, 8), padding: MySpacing.xy(8, 8),
child: Row( child: Row(
@ -200,9 +202,8 @@ class _DirectoryViewState extends State<DirectoryView> {
suffixIcon: ValueListenableBuilder<TextEditingValue>( suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: searchController, valueListenable: searchController,
builder: (context, value, _) { builder: (context, value, _) {
if (value.text.isEmpty) { if (value.text.isEmpty)
return const SizedBox.shrink(); return const SizedBox.shrink();
}
return IconButton( return IconButton(
icon: const Icon(Icons.clear, icon: const Icon(Icons.clear,
size: 20, color: Colors.grey), size: 20, color: Colors.grey),
@ -254,7 +255,7 @@ class _DirectoryViewState extends State<DirectoryView> {
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical( borderRadius: BorderRadius.vertical(
top: Radius.circular(20)), top: Radius.circular(5)),
), ),
builder: (_) => builder: (_) =>
const DirectoryFilterBottomSheet(), const DirectoryFilterBottomSheet(),
@ -292,8 +293,7 @@ class _DirectoryViewState extends State<DirectoryView> {
icon: const Icon(Icons.more_vert, icon: const Icon(Icons.more_vert,
size: 20, color: Colors.black87), size: 20, color: Colors.black87),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5)),
),
itemBuilder: (context) { itemBuilder: (context) {
List<PopupMenuEntry<int>> menuItems = []; List<PopupMenuEntry<int>> menuItems = [];
@ -302,17 +302,13 @@ class _DirectoryViewState extends State<DirectoryView> {
const PopupMenuItem<int>( const PopupMenuItem<int>(
enabled: false, enabled: false,
height: 30, height: 30,
child: Text( child: Text("Actions",
"Actions", style: TextStyle(
style: TextStyle( fontWeight: FontWeight.bold,
fontWeight: FontWeight.bold, color: Colors.grey)),
color: Colors.grey,
),
),
), ),
); );
// Conditionally show Create Bucket option
if (permissionController if (permissionController
.hasPermission(Permissions.directoryAdmin) || .hasPermission(Permissions.directoryAdmin) ||
permissionController permissionController
@ -378,13 +374,10 @@ class _DirectoryViewState extends State<DirectoryView> {
const PopupMenuItem<int>( const PopupMenuItem<int>(
enabled: false, enabled: false,
height: 30, height: 30,
child: Text( child: Text("Preferences",
"Preferences", style: TextStyle(
style: TextStyle( fontWeight: FontWeight.bold,
fontWeight: FontWeight.bold, color: Colors.grey)),
color: Colors.grey,
),
),
), ),
); );
@ -398,7 +391,8 @@ class _DirectoryViewState extends State<DirectoryView> {
const Icon(Icons.visibility_off_outlined, const Icon(Icons.visibility_off_outlined,
size: 20, color: Colors.black87), size: 20, color: Colors.black87),
const SizedBox(width: 10), const SizedBox(width: 10),
const Expanded(child: Text('Show Deleted Contacts')), const Expanded(
child: Text('Show Deleted Contacts')),
Switch.adaptive( Switch.adaptive(
value: !controller.isActive.value, value: !controller.isActive.value,
activeColor: Colors.indigo, activeColor: Colors.indigo,
@ -420,211 +414,347 @@ class _DirectoryViewState extends State<DirectoryView> {
], ],
), ),
), ),
// Contact List
Expanded( Expanded(
child: Obx(() { child: Obx(() {
return MyRefreshIndicator( return MyRefreshIndicator(
onRefresh: _refreshDirectory, onRefresh: _refreshDirectory,
backgroundColor: Colors.indigo, backgroundColor: Colors.indigo,
color: Colors.white, color: Colors.white,
child: controller.isLoading.value child: controller.isLoading.value
? ListView.separated( ? ListView.separated(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
itemCount: 10, itemCount: 10,
separatorBuilder: (_, __) => MySpacing.height(12), separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, __) => itemBuilder: (_, __) =>
SkeletonLoaders.contactSkeletonCard(), SkeletonLoaders.contactSkeletonCard(),
) )
: controller.filteredContacts.isEmpty : controller.filteredContacts.isEmpty
? _buildEmptyState() ? _buildEmptyState()
: ListView.separated( : ListView.separated(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.only( padding: MySpacing.only(
left: 8, right: 8, top: 4, bottom: 80), left: 8, right: 8, top: 4, bottom: 80),
itemCount: controller.filteredContacts.length, itemCount: controller.filteredContacts.length,
separatorBuilder: (_, __) => MySpacing.height(12), separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, index) { itemBuilder: (_, index) {
final contact = final contact =
controller.filteredContacts[index]; controller.filteredContacts[index];
final nameParts = contact.name.trim().split(" "); final isDeleted = !controller
final firstName = nameParts.first; .isActive.value; // mark deleted contacts
final lastName = final nameParts =
nameParts.length > 1 ? nameParts.last : ""; contact.name.trim().split(" ");
final tags = final firstName = nameParts.first;
contact.tags.map((tag) => tag.name).toList(); final lastName =
nameParts.length > 1 ? nameParts.last : "";
final tags = contact.tags
.map((tag) => tag.name)
.toList();
return InkWell( return Card(
onTap: () { shape: RoundedRectangleBorder(
Get.to(() => borderRadius: BorderRadius.circular(5),
ContactDetailScreen(contact: contact)); ),
}, elevation: 3,
child: Padding( shadowColor: Colors.grey.withOpacity(0.3),
padding: color: Colors.white,
const EdgeInsets.fromLTRB(12, 10, 12, 0), child: InkWell(
child: Row( borderRadius: BorderRadius.circular(5),
crossAxisAlignment: onTap: isDeleted
CrossAxisAlignment.start, ? null
children: [ : () => Get.to(() =>
Avatar( ContactDetailScreen(
firstName: firstName, contact: contact)),
lastName: lastName, child: Padding(
size: 35), padding: const EdgeInsets.all(12),
MySpacing.width(12), child: Row(
Expanded( crossAxisAlignment:
child: Column( CrossAxisAlignment.start,
crossAxisAlignment: children: [
CrossAxisAlignment.start, // Avatar
children: [ Avatar(
MyText.titleSmall(contact.name, firstName: firstName,
fontWeight: 600, lastName: lastName,
overflow: size: 40,
TextOverflow.ellipsis), backgroundColor: isDeleted
MyText.bodySmall( ? Colors.grey.shade400
contact.organization, : null,
color: Colors.grey[700], ),
overflow: MySpacing.width(12),
TextOverflow.ellipsis), // Contact Info
MySpacing.height(8), Expanded(
if (contact child: Column(
.contactEmails.isNotEmpty) crossAxisAlignment:
GestureDetector( CrossAxisAlignment.start,
onTap: () => children: [
LauncherUtils.launchEmail( MyText.titleSmall(
contact contact.name,
.contactEmails fontWeight: 600,
.first overflow:
.emailAddress), TextOverflow.ellipsis,
onLongPress: () => LauncherUtils color: isDeleted
.copyToClipboard( ? Colors.grey
contact.contactEmails.first : Colors.black87,
.emailAddress,
typeLabel: 'Email',
), ),
child: Padding( MyText.bodySmall(
padding: contact.organization,
const EdgeInsets.only( color: isDeleted
bottom: 4), ? Colors.grey
child: Row( : Colors.grey[700],
children: [ overflow:
const Icon( TextOverflow.ellipsis,
Icons.email_outlined,
size: 16,
color: Colors.indigo),
MySpacing.width(4),
Expanded(
child:
MyText.labelSmall(
contact
.contactEmails
.first
.emailAddress,
overflow: TextOverflow
.ellipsis,
color: Colors.indigo,
decoration:
TextDecoration
.underline,
),
),
],
),
), ),
), MySpacing.height(6),
if (contact if (contact
.contactPhones.isNotEmpty) .contactEmails.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only( padding:
bottom: 8, top: 4), const EdgeInsets.only(
child: Row( bottom: 4),
children: [ child: GestureDetector(
Expanded( onTap: isDeleted
child: GestureDetector( ? null
onTap: () => LauncherUtils : () => LauncherUtils
.launchPhone(contact .launchEmail(contact
.contactPhones .contactEmails
.first .first
.phoneNumber), .emailAddress),
onLongPress: () => onLongPress: isDeleted
LauncherUtils ? null
.copyToClipboard( : () => LauncherUtils
contact .copyToClipboard(
.contactPhones
.first
.phoneNumber,
typeLabel: 'Phone',
),
child: Row(
children: [
const Icon(
Icons
.phone_outlined,
size: 16,
color: Colors
.indigo),
MySpacing.width(4),
Expanded(
child: MyText
.labelSmall(
contact contact
.contactPhones .contactEmails
.first .first
.phoneNumber, .emailAddress,
overflow: typeLabel:
TextOverflow 'Email',
.ellipsis, ),
color: Colors child: Row(
.indigo, children: [
decoration: Icon(
TextDecoration Icons
.underline, .email_outlined,
size: 16,
color: isDeleted
? Colors.grey
: Colors
.indigo),
MySpacing.width(4),
Expanded(
child: MyText
.labelSmall(
contact
.contactEmails
.first
.emailAddress,
overflow:
TextOverflow
.ellipsis,
color: isDeleted
? Colors.grey
: Colors
.indigo,
decoration:
TextDecoration
.underline,
),
),
],
),
),
),
if (contact
.contactPhones.isNotEmpty)
Padding(
padding:
const EdgeInsets.only(
bottom: 8, top: 4),
child: Row(
children: [
Expanded(
child:
GestureDetector(
onTap: isDeleted
? null
: () => LauncherUtils
.launchPhone(contact
.contactPhones
.first
.phoneNumber),
onLongPress:
isDeleted
? null
: () =>
LauncherUtils
.copyToClipboard(
contact
.contactPhones
.first
.phoneNumber,
typeLabel:
'Phone',
),
child: Row(
children: [
Icon(
Icons
.phone_outlined,
size: 16,
color: isDeleted
? Colors
.grey
: Colors
.indigo),
MySpacing.width(
4),
Expanded(
child: MyText
.labelSmall(
contact
.contactPhones
.first
.phoneNumber,
overflow:
TextOverflow
.ellipsis,
color: isDeleted
? Colors
.grey
: Colors
.indigo,
decoration:
TextDecoration
.underline,
),
),
],
),
),
),
MySpacing.width(8),
GestureDetector(
onTap: isDeleted
? null
: () => LauncherUtils
.launchWhatsApp(contact
.contactPhones
.first
.phoneNumber),
child: FaIcon(
FontAwesomeIcons
.whatsapp,
color: isDeleted
? Colors.grey
: Colors
.green,
size: 25),
),
],
),
),
if (tags.isNotEmpty)
Padding(
padding:
const EdgeInsets.only(
top: 0),
child: Wrap(
spacing: 6,
runSpacing: 2,
children: tags
.map(
(tag) => Chip(
label: Text(tag),
backgroundColor:
Colors.indigo
.shade50,
labelStyle: TextStyle(
color: isDeleted
? Colors
.grey
: Colors
.indigo,
fontSize: 12),
visualDensity:
VisualDensity
.compact,
shape:
RoundedRectangleBorder(
borderRadius:
BorderRadius
.circular(
5),
), ),
), ),
], )
), .toList(),
),
), ),
MySpacing.width(8), ),
GestureDetector( ],
onTap: () => LauncherUtils ),
.launchWhatsApp( ),
contact // Actions Column (Arrow + Icons)
.contactPhones Column(
.first children: [
.phoneNumber), IconButton(
child: const FaIcon( icon: Icon(
FontAwesomeIcons isDeleted
.whatsapp, ? Icons.restore
color: Colors.green, : Icons.delete,
size: 25, color: isDeleted
), ? Colors.green
), : Colors.redAccent,
], size: 20,
), ),
onPressed: () async {
await Get.dialog(
ConfirmDialog(
title: isDeleted
? "Restore Contact"
: "Delete Contact",
message: isDeleted
? "Are you sure you want to restore this contact?"
: "Are you sure you want to delete this contact?",
confirmText: isDeleted
? "Restore"
: "Delete",
confirmColor: isDeleted
? Colors.green
: Colors.redAccent,
icon: isDeleted
? Icons.restore
: Icons
.delete_forever,
onConfirm: () async {
if (isDeleted) {
await controller
.restoreContact(
contact.id);
} else {
await controller
.deleteContact(
contact.id);
}
},
),
barrierDismissible: false,
);
},
), ),
if (tags.isNotEmpty) ...[ const SizedBox(height: 4),
MySpacing.height(2), Icon(
MyText.labelSmall(tags.join(', '), Icons.arrow_forward_ios,
color: Colors.grey[500], color: Colors.grey,
maxLines: 1, size: 20,
overflow: )
TextOverflow.ellipsis),
], ],
], ),
),
),
Column(
children: [
const Icon(Icons.arrow_forward_ios,
color: Colors.grey, size: 16),
MySpacing.height(8),
], ],
), ),
], ),
), ),
), );
); }));
},
),
);
}), }),
) )
], ],

View File

@ -3,8 +3,8 @@ import 'package:get/get.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:flutter_quill/flutter_quill.dart' as quill;
import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart'; import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart';
import 'package:flutter_html/flutter_html.dart' as html; import 'package:flutter_html/flutter_html.dart' as html;
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/controller/directory/notes_controller.dart'; import 'package:marco/controller/directory/notes_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
@ -68,7 +68,6 @@ class NotesView extends StatelessWidget {
} }
if (inList) buffer.write('</ul>'); if (inList) buffer.write('</ul>');
return buffer.toString(); return buffer.toString();
} }
@ -98,7 +97,7 @@ class NotesView extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
/// 🔍 Search + Refresh (Top Row) /// 🔍 Search Field
Padding( Padding(
padding: MySpacing.xy(8, 8), padding: MySpacing.xy(8, 8),
child: Row( child: Row(
@ -132,7 +131,7 @@ class NotesView extends StatelessWidget {
), ),
), ),
/// 📄 Notes List View /// 📄 Notes List
Expanded( Expanded(
child: Obx(() { child: Obx(() {
if (controller.isLoading.value) { if (controller.isLoading.value) {
@ -151,9 +150,7 @@ class NotesView extends StatelessWidget {
child: ConstrainedBox( child: ConstrainedBox(
constraints: constraints:
BoxConstraints(minHeight: constraints.maxHeight), BoxConstraints(minHeight: constraints.maxHeight),
child: Center( child: Center(child: _buildEmptyState()),
child: _buildEmptyState(),
),
), ),
); );
}, },
@ -204,56 +201,166 @@ class NotesView extends StatelessWidget {
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
padding: MySpacing.xy(12, 12), padding: MySpacing.xy(12, 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isEditing ? Colors.indigo[50] : Colors.white, color: isEditing
? Colors.indigo[50]
: note.isActive
? Colors.white
: Colors.grey.shade100,
border: Border.all( border: Border.all(
color: color: note.isActive
isEditing ? Colors.indigo : Colors.grey.shade300, ? (isEditing
? Colors.indigo
: Colors.grey.shade300)
: Colors.grey.shade400,
width: 1.1, width: 1.1,
), ),
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
boxShadow: const [ boxShadow: const [
BoxShadow( BoxShadow(
color: Colors.black12, color: Colors.black12,
blurRadius: 4, blurRadius: 4,
offset: Offset(0, 2)), offset: Offset(0, 2),
),
], ],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
/// Header Row // Header & Note content (fade them if inactive)
Row( AnimatedOpacity(
crossAxisAlignment: CrossAxisAlignment.start, duration: const Duration(milliseconds: 200),
children: [ opacity: note.isActive ? 1.0 : 0.6,
Avatar( child: IgnorePointer(
firstName: initials, lastName: '', size: 40), ignoring: !note.isActive,
MySpacing.width(12), child: Column(
Expanded( crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, Row(
children: [ crossAxisAlignment:
MyText.titleSmall( CrossAxisAlignment.start,
"${note.contactName} (${note.organizationName})", children: [
fontWeight: 600, Avatar(
overflow: TextOverflow.ellipsis, firstName: initials,
color: Colors.indigo[800], lastName: '',
size: 40,
backgroundColor: note.isActive
? null
: Colors.grey.shade400,
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
MyText.titleSmall(
"${note.contactName} (${note.organizationName})",
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: note.isActive
? Colors.indigo[800]
: Colors.grey,
),
MyText.bodySmall(
"by ${note.createdBy.firstName}$createdDate, $createdTime",
color: note.isActive
? Colors.grey[600]
: Colors.grey,
),
],
),
),
],
),
MySpacing.height(12),
if (isEditing && quillController != null)
CommentEditorCard(
controller: quillController,
onCancel: () =>
controller.editingNoteId.value = null,
onSave: (quillCtrl) async {
final delta =
quillCtrl.document.toDelta();
final htmlOutput =
_convertDeltaToHtml(delta);
final updated =
note.copyWith(note: htmlOutput);
await controller.updateNote(updated);
controller.editingNoteId.value = null;
},
)
else
html.Html(
data: note.note,
style: {
"body": html.Style(
margin: html.Margins.zero,
padding: html.HtmlPaddings.zero,
fontSize: html.FontSize.medium,
color: note.isActive
? Colors.black87
: Colors.grey,
),
},
), ),
MyText.bodySmall( ],
"by ${note.createdBy.firstName}$createdDate, $createdTime",
color: Colors.grey[600],
),
],
),
), ),
),
),
/// Edit / Delete / Restore Icons // 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) if (!note.isActive)
IconButton( IconButton(
icon: const Icon(Icons.restore, icon: const Icon(
color: Colors.green, size: 20), Icons.restore,
color: Colors.green,
size: 22,
),
tooltip: "Restore", tooltip: "Restore",
padding: EdgeInsets
.zero,
onPressed: () async { onPressed: () async {
await Get.dialog( await Get.dialog(
ConfirmDialog( ConfirmDialog(
@ -272,87 +379,9 @@ class NotesView extends StatelessWidget {
barrierDismissible: false, barrierDismissible: false,
); );
}, },
)
else
Row(
mainAxisSize: MainAxisSize.min,
children: [
/// Edit Icon
IconButton(
icon: Icon(
isEditing ? Icons.close : Icons.edit,
color: Colors.indigo,
size: 20,
),
padding: EdgeInsets
.zero,
constraints:
const BoxConstraints(),
onPressed: () {
controller.editingNoteId.value =
isEditing ? null : note.id;
},
),
const SizedBox(
width: 6),
/// Delete Icon
IconButton(
icon: const Icon(Icons.delete_outline,
color: Colors.redAccent, size: 20),
padding: EdgeInsets.zero,
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,
);
},
),
],
), ),
], ],
), ),
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,
),
},
),
], ],
), ),
); );

View File

@ -27,8 +27,8 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
final DocumentDetailsController controller = final DocumentDetailsController controller =
Get.find<DocumentDetailsController>(); Get.find<DocumentDetailsController>();
final PermissionController permissionController = final permissionController = Get.put(PermissionController());
Get.find<PermissionController>();
@override @override
void initState() { void initState() {
super.initState(); super.initState();

View File

@ -1,24 +1,24 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/document/user_document_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/model/document/user_document_filter_bottom_sheet.dart';
import 'package:marco/model/document/documents_list_model.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/controller/document/document_details_controller.dart';
import 'package:marco/model/document/document_upload_bottom_sheet.dart';
import 'package:marco/controller/document/document_upload_controller.dart'; import 'package:marco/controller/document/document_upload_controller.dart';
import 'package:marco/view/document/document_details_page.dart'; import 'package:marco/controller/document/user_document_controller.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/custom_app_bar.dart'; import 'package:marco/helpers/widgets/custom_app_bar.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/controller/document/document_details_controller.dart'; import 'package:marco/model/document/document_upload_bottom_sheet.dart';
import 'dart:convert'; import 'package:marco/model/document/documents_list_model.dart';
import 'package:marco/model/document/user_document_filter_bottom_sheet.dart';
import 'package:marco/view/document/document_details_page.dart';
class UserDocumentsPage extends StatefulWidget { class UserDocumentsPage extends StatefulWidget {
final String? entityId; final String? entityId;
@ -36,10 +36,9 @@ class UserDocumentsPage extends StatefulWidget {
class _UserDocumentsPageState extends State<UserDocumentsPage> { class _UserDocumentsPageState extends State<UserDocumentsPage> {
final DocumentController docController = Get.put(DocumentController()); final DocumentController docController = Get.put(DocumentController());
final PermissionController permissionController = final PermissionController permissionController = Get.put(PermissionController());
Get.find<PermissionController>(); final DocumentDetailsController controller = Get.put(DocumentDetailsController());
final DocumentDetailsController controller =
Get.put(DocumentDetailsController());
String get entityTypeId => widget.isEmployee String get entityTypeId => widget.isEmployee
? Permissions.employeeEntity ? Permissions.employeeEntity
: Permissions.projectEntity; : Permissions.projectEntity;
@ -68,12 +67,9 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
} }
Widget _buildDocumentTile(DocumentItem doc, bool showDateHeader) { Widget _buildDocumentTile(DocumentItem doc, bool showDateHeader) {
final uploadDate = final uploadDate = DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal());
DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal());
final uploader = doc.uploadedBy.firstName.isNotEmpty final uploader = doc.uploadedBy.firstName.isNotEmpty
? "Added by ${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}" ? "Added by ${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}".trim()
.trim()
: "Added by you"; : "Added by you";
return Column( return Column(
@ -91,7 +87,6 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
), ),
InkWell( InkWell(
onTap: () { onTap: () {
// 👉 Navigate to details page
Get.to(() => DocumentDetailsPage(documentId: doc.id)); Get.to(() => DocumentDetailsPage(documentId: doc.id));
}, },
child: Container( child: Container(
@ -146,92 +141,90 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
], ],
), ),
), ),
PopupMenuButton<String>( Obx(() {
icon: const Icon(Icons.more_vert, color: Colors.black54), // React to permission changes
onSelected: (value) async { return PopupMenuButton<String>(
if (value == "delete") { icon: const Icon(Icons.more_vert, color: Colors.black54),
// existing delete flow (unchanged) onSelected: (value) async {
final result = await showDialog<bool>( if (value == "delete") {
context: context, final result = await showDialog<bool>(
builder: (_) => ConfirmDialog( context: context,
title: "Delete Document", builder: (_) => ConfirmDialog(
message: title: "Delete Document",
"Are you sure you want to delete \"${doc.name}\"?\nThis action cannot be undone.", message:
confirmText: "Delete", "Are you sure you want to delete \"${doc.name}\"?\nThis action cannot be undone.",
cancelText: "Cancel", confirmText: "Delete",
icon: Icons.delete_forever, cancelText: "Cancel",
confirmColor: Colors.redAccent, icon: Icons.delete_forever,
onConfirm: () async { confirmColor: Colors.redAccent,
final success = onConfirm: () async {
await docController.toggleDocumentActive( final success =
doc.id, await docController.toggleDocumentActive(
isActive: false, doc.id,
entityTypeId: entityTypeId, isActive: false,
entityId: resolvedEntityId, entityTypeId: entityTypeId,
); entityId: resolvedEntityId,
);
if (success) { if (success) {
showAppSnackbar( showAppSnackbar(
title: "Deleted", title: "Deleted",
message: "Document deleted successfully", message: "Document deleted successfully",
type: SnackbarType.success, type: SnackbarType.success,
); );
} else { } else {
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Failed to delete document", message: "Failed to delete document",
type: SnackbarType.error, type: SnackbarType.error,
); );
throw Exception( throw Exception("Failed to delete");
"Failed to delete"); // keep dialog open }
} },
}, ),
);
if (result == true) {
debugPrint("✅ Document deleted and removed from list");
}
} else if (value == "restore") {
final success = await docController.toggleDocumentActive(
doc.id,
isActive: true,
entityTypeId: entityTypeId,
entityId: resolvedEntityId,
);
if (success) {
showAppSnackbar(
title: "Restored",
message: "Document restored successfully",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to restore document",
type: SnackbarType.error,
);
}
}
},
itemBuilder: (context) => [
if (doc.isActive &&
permissionController.hasPermission(Permissions.deleteDocument))
const PopupMenuItem(
value: "delete",
child: Text("Delete"),
)
else if (!doc.isActive &&
permissionController.hasPermission(Permissions.modifyDocument))
const PopupMenuItem(
value: "restore",
child: Text("Restore"),
), ),
); ],
if (result == true) { );
debugPrint("✅ Document deleted and removed from list"); }),
}
} else if (value == "restore") {
// existing activate flow (unchanged)
final success = await docController.toggleDocumentActive(
doc.id,
isActive: true,
entityTypeId: entityTypeId,
entityId: resolvedEntityId,
);
if (success) {
showAppSnackbar(
title: "Restored",
message: "Document reastored 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"),
),
],
),
], ],
), ),
), ),
@ -267,7 +260,6 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
padding: MySpacing.xy(8, 8), padding: MySpacing.xy(8, 8),
child: Row( child: Row(
children: [ children: [
// 🔍 Search Bar
Expanded( Expanded(
child: SizedBox( child: SizedBox(
height: 35, height: 35,
@ -283,15 +275,13 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
}, },
decoration: InputDecoration( decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 12),
prefixIcon: prefixIcon: const Icon(Icons.search, size: 20, color: Colors.grey),
const Icon(Icons.search, size: 20, color: Colors.grey),
suffixIcon: ValueListenableBuilder<TextEditingValue>( suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: docController.searchController, valueListenable: docController.searchController,
builder: (context, value, _) { builder: (context, value, _) {
if (value.text.isEmpty) return const SizedBox.shrink(); if (value.text.isEmpty) return const SizedBox.shrink();
return IconButton( return IconButton(
icon: const Icon(Icons.clear, icon: const Icon(Icons.clear, size: 20, color: Colors.grey),
size: 20, color: Colors.grey),
onPressed: () { onPressed: () {
docController.searchController.clear(); docController.searchController.clear();
docController.searchQuery.value = ''; docController.searchQuery.value = '';
@ -320,8 +310,6 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
), ),
), ),
MySpacing.width(8), MySpacing.width(8),
// 🛠 Filter Icon with indicator
Obx(() { Obx(() {
final isFilterActive = docController.hasActiveFilters(); final isFilterActive = docController.hasActiveFilters();
return Stack( return Stack(
@ -337,18 +325,13 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
child: IconButton( child: IconButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: BoxConstraints(), constraints: BoxConstraints(),
icon: Icon( icon: Icon(Icons.tune, size: 20, color: Colors.black87),
Icons.tune,
size: 20,
color: Colors.black87,
),
onPressed: () { onPressed: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: borderRadius: BorderRadius.vertical(top: Radius.circular(5)),
BorderRadius.vertical(top: Radius.circular(5)),
), ),
builder: (_) => UserDocumentFilterBottomSheet( builder: (_) => UserDocumentFilterBottomSheet(
entityId: resolvedEntityId, entityId: resolvedEntityId,
@ -375,8 +358,6 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
); );
}), }),
MySpacing.width(10), MySpacing.width(10),
// Menu (Show Inactive toggle)
Container( Container(
height: 35, height: 35,
width: 35, width: 35,
@ -387,8 +368,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
), ),
child: PopupMenuButton<int>( child: PopupMenuButton<int>(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
icon: icon: const Icon(Icons.more_vert, size: 20, color: Colors.black87),
const Icon(Icons.more_vert, size: 20, color: Colors.black87),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
@ -439,8 +419,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
Widget _buildStatusHeader() { Widget _buildStatusHeader() {
return Obx(() { return Obx(() {
final isInactive = docController.showInactive.value; if (!docController.showInactive.value) return const SizedBox.shrink();
if (!isInactive) return const SizedBox.shrink(); // hide when active
return Container( return Container(
width: double.infinity, width: double.infinity,
@ -448,18 +427,11 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
color: Colors.red.shade50, color: Colors.red.shade50,
child: Row( child: Row(
children: [ children: [
Icon( Icon(Icons.visibility_off, color: Colors.red, size: 18),
Icons.visibility_off,
color: Colors.red,
size: 18,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
"Showing Deleted Documents", "Showing Deleted Documents",
style: TextStyle( style: TextStyle(color: Colors.red, fontWeight: FontWeight.w600),
color: Colors.red,
fontWeight: FontWeight.w600,
),
), ),
], ],
), ),
@ -468,30 +440,33 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
} }
Widget _buildBody(BuildContext context) { Widget _buildBody(BuildContext context) {
// 🔒 Check for viewDocument permission
if (!permissionController.hasPermission(Permissions.viewDocument)) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.lock_outline, size: 60, color: Colors.grey),
MySpacing.height(18),
MyText.titleMedium(
'Access Denied',
fontWeight: 600,
color: Colors.grey,
),
MySpacing.height(10),
MyText.bodySmall(
'You do not have permission to view documents.',
color: Colors.grey,
),
],
),
);
}
return Obx(() { return Obx(() {
if (permissionController.permissions.isEmpty) {
return Center(child: CircularProgressIndicator());
}
if (!permissionController.hasPermission(Permissions.viewDocument)) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.lock_outline, size: 60, color: Colors.grey),
MySpacing.height(18),
MyText.titleMedium(
'Access Denied',
fontWeight: 600,
color: Colors.grey,
),
MySpacing.height(10),
MyText.bodySmall(
'You do not have permission to view documents.',
color: Colors.grey,
),
],
),
);
}
if (docController.isLoading.value && docController.documents.isEmpty) { if (docController.isLoading.value && docController.documents.isEmpty) {
return SingleChildScrollView( return SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
@ -510,8 +485,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
onRefresh: () async { onRefresh: () async {
final combinedFilter = { final combinedFilter = {
'uploadedByIds': docController.selectedUploadedBy.toList(), 'uploadedByIds': docController.selectedUploadedBy.toList(),
'documentCategoryIds': 'documentCategoryIds': docController.selectedCategory.toList(),
docController.selectedCategory.toList(),
'documentTypeIds': docController.selectedType.toList(), 'documentTypeIds': docController.selectedType.toList(),
'documentTagIds': docController.selectedTag.toList(), 'documentTagIds': docController.selectedTag.toList(),
}; };
@ -525,9 +499,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
}, },
child: ListView( child: ListView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: docs.isEmpty padding: docs.isEmpty ? null : const EdgeInsets.fromLTRB(0, 0, 0, 80),
? null
: const EdgeInsets.fromLTRB(0, 0, 0, 80),
children: docs.isEmpty children: docs.isEmpty
? [ ? [
SizedBox( SizedBox(
@ -543,8 +515,8 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
final currentDate = DateFormat("dd MMM yyyy") final currentDate = DateFormat("dd MMM yyyy")
.format(doc.uploadedAt.toLocal()); .format(doc.uploadedAt.toLocal());
final prevDate = index > 0 final prevDate = index > 0
? DateFormat("dd MMM yyyy").format( ? DateFormat("dd MMM yyyy")
docs[index - 1].uploadedAt.toLocal()) .format(docs[index - 1].uploadedAt.toLocal())
: null; : null;
final showDateHeader = currentDate != prevDate; final showDateHeader = currentDate != prevDate;
@ -591,58 +563,61 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
) )
: null, : null,
body: _buildBody(context), body: _buildBody(context),
floatingActionButton: permissionController floatingActionButton: Obx(() {
.hasPermission(Permissions.uploadDocument) if (permissionController.permissions.isEmpty) return SizedBox.shrink();
? FloatingActionButton.extended(
onPressed: () {
final uploadController = Get.put(DocumentUploadController());
showModalBottomSheet( return permissionController.hasPermission(Permissions.uploadDocument)
context: context, ? FloatingActionButton.extended(
isScrollControlled: true, onPressed: () {
backgroundColor: Colors.transparent, final uploadController = Get.put(DocumentUploadController());
builder: (_) => DocumentUploadBottomSheet(
isEmployee: widget.isEmployee,
onSubmit: (data) async {
final success = await uploadController.uploadDocument(
name: data["name"],
description: data["description"],
documentId: data["documentId"],
entityId: resolvedEntityId,
documentTypeId: data["documentTypeId"],
fileName: data["attachment"]["fileName"],
base64Data: data["attachment"]["base64Data"],
contentType: data["attachment"]["contentType"],
fileSize: data["attachment"]["fileSize"],
);
if (success) { showModalBottomSheet(
Navigator.pop(context); context: context,
docController.fetchDocuments( isScrollControlled: true,
entityTypeId: entityTypeId, backgroundColor: Colors.transparent,
builder: (_) => DocumentUploadBottomSheet(
isEmployee: widget.isEmployee,
onSubmit: (data) async {
final success = await uploadController.uploadDocument(
name: data["name"],
description: data["description"],
documentId: data["documentId"],
entityId: resolvedEntityId, entityId: resolvedEntityId,
reset: true, documentTypeId: data["documentTypeId"],
fileName: data["attachment"]["fileName"],
base64Data: data["attachment"]["base64Data"],
contentType: data["attachment"]["contentType"],
fileSize: data["attachment"]["fileSize"],
); );
} else {
showAppSnackbar( if (success) {
title: "Error", Navigator.pop(context);
message: "Upload failed, please try again", docController.fetchDocuments(
type: SnackbarType.error, entityTypeId: entityTypeId,
); entityId: resolvedEntityId,
} reset: true,
}, );
), } else {
); showAppSnackbar(
}, title: "Error",
icon: const Icon(Icons.add, color: Colors.white), message: "Upload failed, please try again",
label: MyText.bodyMedium( type: SnackbarType.error,
"Add Document", );
color: Colors.white, }
fontWeight: 600, },
), ),
backgroundColor: Colors.red, );
) },
: null, icon: const Icon(Icons.add, color: Colors.white),
label: MyText.bodyMedium(
"Add Document",
color: Colors.white,
fontWeight: 600,
),
backgroundColor: Colors.red,
)
: SizedBox.shrink();
}),
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
); );
} }

View File

@ -30,8 +30,8 @@ class EmployeeDetailPage extends StatefulWidget {
class _EmployeeDetailPageState extends State<EmployeeDetailPage> { class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
final EmployeesScreenController controller = final EmployeesScreenController controller =
Get.put(EmployeesScreenController()); Get.put(EmployeesScreenController());
final PermissionController _permissionController = final PermissionController permissionController =
Get.find<PermissionController>(); Get.put(PermissionController());
@override @override
void initState() { void initState() {
@ -251,8 +251,8 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
), ),
), ),
IconButton( IconButton(
icon: const Icon(Icons.edit, icon:
size: 24, color: Colors.red), const Icon(Icons.edit, size: 24, color: Colors.red),
onPressed: () async { onPressed: () async {
final result = final result =
await showModalBottomSheet<Map<String, dynamic>>( await showModalBottomSheet<Map<String, dynamic>>(
@ -265,10 +265,14 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
'first_name': employee.firstName, 'first_name': employee.firstName,
'last_name': employee.lastName, 'last_name': employee.lastName,
'phone_number': employee.phoneNumber, 'phone_number': employee.phoneNumber,
'email': employee.email,
'hasApplicationAccess':
employee.hasApplicationAccess,
'gender': employee.gender.toLowerCase(), 'gender': employee.gender.toLowerCase(),
'job_role_id': employee.jobRoleId, 'job_role_id': employee.jobRoleId,
'joining_date': 'joining_date':
employee.joiningDate?.toIso8601String(), employee.joiningDate?.toIso8601String(),
'organization_id': employee.organizationId,
}, },
), ),
); );
@ -289,7 +293,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
); );
}), }),
floatingActionButton: Obx(() { floatingActionButton: Obx(() {
if (!_permissionController.hasPermission(Permissions.assignToProject)) { if (!permissionController.hasPermission(Permissions.assignToProject)) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
if (controller.isLoadingEmployeeDetails.value || if (controller.isLoadingEmployeeDetails.value ||

View File

@ -29,8 +29,8 @@ class EmployeesScreen extends StatefulWidget {
class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin { class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
final EmployeesScreenController _employeeController = final EmployeesScreenController _employeeController =
Get.put(EmployeesScreenController()); Get.put(EmployeesScreenController());
final PermissionController _permissionController = final PermissionController permissionController =
Get.find<PermissionController>(); Get.put(PermissionController());
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs; final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs;
final OrganizationController _organizationController = final OrganizationController _organizationController =
@ -248,33 +248,46 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
} }
Widget _buildFloatingActionButton() { Widget _buildFloatingActionButton() {
if (!_permissionController.hasPermission(Permissions.manageEmployees)) { return Obx(() {
return const SizedBox.shrink(); // Show nothing while permissions are loading
} if (permissionController.isLoading.value) {
return const SizedBox.shrink();
}
return InkWell( // Show FAB only if user has Manage Employees permission
onTap: _onAddNewEmployee, final hasPermission =
borderRadius: BorderRadius.circular(28), permissionController.hasPermission(Permissions.manageEmployees);
child: Container( if (!hasPermission) {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), return const SizedBox.shrink();
decoration: BoxDecoration( }
color: Colors.red,
borderRadius: BorderRadius.circular(28), return InkWell(
boxShadow: const [ onTap: _onAddNewEmployee,
BoxShadow( borderRadius: BorderRadius.circular(28),
color: Colors.black26, blurRadius: 6, offset: Offset(0, 3)) child: Container(
], padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(28),
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 6,
offset: Offset(0, 3),
)
],
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, color: Colors.white),
SizedBox(width: 8),
Text('Add New Employee', style: TextStyle(color: Colors.white)),
],
),
), ),
child: const Row( );
mainAxisSize: MainAxisSize.min, });
children: [
Icon(Icons.add, color: Colors.white),
SizedBox(width: 8),
Text('Add New Employee', style: TextStyle(color: Colors.white)),
],
),
),
);
} }
Widget _buildSearchAndActionRow() { Widget _buildSearchAndActionRow() {
@ -371,60 +384,63 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
} }
Widget _buildPopupMenu() { Widget _buildPopupMenu() {
if (!_permissionController.hasPermission(Permissions.viewAllEmployees)) { return Obx(() {
return const SizedBox.shrink(); if (permissionController.isLoading.value ||
} !permissionController.hasPermission(Permissions.viewAllEmployees)) {
return const SizedBox.shrink();
}
return PopupMenuButton<String>( return PopupMenuButton<String>(
icon: Stack( icon: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
const Icon(Icons.tune, color: Colors.black), const Icon(Icons.tune, color: Colors.black),
Obx(() => _employeeController.isAllEmployeeSelected.value Obx(() => _employeeController.isAllEmployeeSelected.value
? Positioned( ? Positioned(
right: -1, right: -1,
top: -1, top: -1,
child: Container( child: Container(
width: 10, width: 10,
height: 10, height: 10,
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Colors.red, shape: BoxShape.circle), color: Colors.red, shape: BoxShape.circle),
),
)
: const SizedBox.shrink()),
],
),
onSelected: (value) async {
if (value == 'all_employees') {
_employeeController.isAllEmployeeSelected.toggle();
await _initEmployees();
_employeeController.update(['employee_screen_controller']);
}
},
itemBuilder: (_) => [
PopupMenuItem<String>(
value: 'all_employees',
child: Obx(
() => Row(
children: [
Checkbox(
value: _employeeController.isAllEmployeeSelected.value,
onChanged: (_) => Navigator.pop(context, 'all_employees'),
checkColor: Colors.white,
activeColor: Colors.blueAccent,
side: const BorderSide(color: Colors.black, width: 1.5),
fillColor: MaterialStateProperty.resolveWith<Color>(
(states) => states.contains(MaterialState.selected)
? Colors.blueAccent
: Colors.white),
), ),
) const Text('All Employees'),
: const SizedBox.shrink()), ],
], ),
),
onSelected: (value) async {
if (value == 'all_employees') {
_employeeController.isAllEmployeeSelected.toggle();
await _initEmployees();
_employeeController.update(['employee_screen_controller']);
}
},
itemBuilder: (_) => [
PopupMenuItem<String>(
value: 'all_employees',
child: Obx(
() => Row(
children: [
Checkbox(
value: _employeeController.isAllEmployeeSelected.value,
onChanged: (_) => Navigator.pop(context, 'all_employees'),
checkColor: Colors.white,
activeColor: Colors.blueAccent,
side: const BorderSide(color: Colors.black, width: 1.5),
fillColor: MaterialStateProperty.resolveWith<Color>(
(states) => states.contains(MaterialState.selected)
? Colors.blueAccent
: Colors.white),
),
const Text('All Employees'),
],
), ),
), ),
), ],
], );
); });
} }
Widget _buildEmployeeList() { Widget _buildEmployeeList() {

View File

@ -33,7 +33,7 @@ class ExpenseDetailScreen extends StatefulWidget {
class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> { class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
final controller = Get.put(ExpenseDetailController()); final controller = Get.put(ExpenseDetailController());
final projectController = Get.find<ProjectController>(); final projectController = Get.find<ProjectController>();
final permissionController = Get.find<PermissionController>(); final permissionController = Get.put(PermissionController());
EmployeeInfo? employeeInfo; EmployeeInfo? employeeInfo;
final RxBool canSubmit = false.obs; final RxBool canSubmit = false.obs;

View File

@ -26,7 +26,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
final searchController = TextEditingController(); final searchController = TextEditingController();
final expenseController = Get.put(ExpenseController()); final expenseController = Get.put(ExpenseController());
final projectController = Get.find<ProjectController>(); final projectController = Get.find<ProjectController>();
final permissionController = Get.find<PermissionController>(); final permissionController = Get.put(PermissionController());
@override @override
void initState() { void initState() {
@ -81,7 +81,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
.toList(); .toList();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
@ -106,7 +106,7 @@ Widget build(BuildContext context) {
// ---------------- Gray background for rest ---------------- // ---------------- Gray background for rest ----------------
Expanded( Expanded(
child: Container( child: Container(
color: Colors.grey[100], // Light gray background color: Colors.grey[100],
child: Column( child: Column(
children: [ children: [
// ---------------- Search ---------------- // ---------------- Search ----------------
@ -137,14 +137,24 @@ Widget build(BuildContext context) {
], ],
), ),
floatingActionButton: // FAB reacts only to upload permission
permissionController.hasPermission(Permissions.expenseUpload) floatingActionButton: Obx(() {
? FloatingActionButton( // Show loader or hide FAB while permissions are loading
backgroundColor: Colors.red, if (permissionController.permissions.isEmpty) {
onPressed: showAddExpenseBottomSheet, return const SizedBox.shrink();
child: const Icon(Icons.add, color: Colors.white), }
)
: null, 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();
}),
); );
} }

View File

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

View File

@ -9,10 +9,12 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/model/employees/employee_info.dart'; import 'package:marco/model/employees/employee_info.dart';
import 'package:marco/controller/auth/mpin_controller.dart'; import 'package:marco/controller/auth/mpin_controller.dart';
import 'package:marco/controller/tenant/tenant_selection_controller.dart';
import 'package:marco/view/employees/employee_profile_screen.dart'; import 'package:marco/view/employees/employee_profile_screen.dart';
import 'package:marco/helpers/services/tenant_service.dart'; import 'package:marco/helpers/services/tenant_service.dart';
import 'package:marco/view/tenant/tenant_selection_screen.dart'; import 'package:marco/view/tenant/tenant_selection_screen.dart';
import 'package:marco/controller/tenant/tenant_switch_controller.dart';
class UserProfileBar extends StatefulWidget { class UserProfileBar extends StatefulWidget {
final bool isCondensed; final bool isCondensed;
@ -27,21 +29,13 @@ class _UserProfileBarState extends State<UserProfileBar>
late EmployeeInfo employeeInfo; late EmployeeInfo employeeInfo;
bool _isLoading = true; bool _isLoading = true;
bool hasMpin = true; bool hasMpin = true;
late final TenantSelectionController _tenantController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tenantController = Get.put(TenantSelectionController());
_initData(); _initData();
} }
@override
void dispose() {
Get.delete<TenantSelectionController>();
super.dispose();
}
Future<void> _initData() async { Future<void> _initData() async {
employeeInfo = LocalStorage.getEmployeeInfo()!; employeeInfo = LocalStorage.getEmployeeInfo()!;
hasMpin = await LocalStorage.getIsMpin(); hasMpin = await LocalStorage.getIsMpin();
@ -122,93 +116,101 @@ 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() { /// Row widget to switch tenant with popup menu (button only)
return Padding( Widget _switchTenantRow() {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), // Use the dedicated switch controller
child: Obx(() { final TenantSwitchController tenantSwitchController =
if (_tenantController.isLoading.value) return _loadingTenantContainer(); Get.put(TenantSwitchController());
final tenants = _tenantController.tenants; return Padding(
if (tenants.isEmpty) return _noTenantContainer(); padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Obx(() {
if (tenantSwitchController.isLoading.value) {
return _loadingTenantContainer();
}
final selectedTenant = TenantService.currentTenant; final tenants = tenantSwitchController.tenants;
if (tenants.isEmpty) return _noTenantContainer();
// Sort tenants: selected tenant first final selectedTenant = TenantService.currentTenant;
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>( // Sort tenants: selected tenant first
onSelected: (tenantId) => final sortedTenants = List.of(tenants);
_tenantController.onTenantSelected(tenantId), if (selectedTenant != null) {
itemBuilder: (_) => sortedTenants.map((tenant) { sortedTenants.sort((a, b) {
return PopupMenuItem( if (a.id == selectedTenant.id) return -1;
value: tenant.id, if (b.id == selectedTenant.id) return 1;
child: Row( return 0;
children: [ });
ClipRRect( }
borderRadius: BorderRadius.circular(8),
child: Container( return PopupMenuButton<String>(
width: 20, onSelected: (tenantId) =>
height: 20, tenantSwitchController.switchTenant(tenantId),
color: Colors.grey.shade200, itemBuilder: (_) => sortedTenants.map((tenant) {
child: TenantLogo(logoImage: tenant.logoImage), return PopupMenuItem(
), value: tenant.id,
),
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( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Icon(Icons.swap_horiz, color: Colors.blue.shade600), 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( Expanded(
child: Padding( child: Text(
padding: const EdgeInsets.symmetric(horizontal: 6), tenant.name,
child: Text( maxLines: 1,
"Switch Organization", overflow: TextOverflow.ellipsis,
maxLines: 1, style: TextStyle(
overflow: TextOverflow.ellipsis, fontWeight: tenant.id == selectedTenant?.id
style: TextStyle( ? FontWeight.bold
color: Colors.blue, fontWeight: FontWeight.bold), : FontWeight.w600,
color: tenant.id == selectedTenant?.id
? Colors.blueAccent
: Colors.black87,
), ),
), ),
), ),
Icon(Icons.arrow_drop_down, color: Colors.blue.shade600), 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( Widget _loadingTenantContainer() => Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),

View File

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

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

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

View File

@ -1,572 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/theme/app_theme.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/utils/my_shadow.dart';
import 'package:marco/helpers/widgets/my_card.dart';
import 'package:marco/helpers/widgets/my_container.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/task_planning/daily_task_controller.dart';
import 'package:marco/model/dailyTaskPlanning/daily_progress_report_filter.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/model/dailyTaskPlanning/task_action_buttons.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 DailyProgressReportScreen extends StatefulWidget {
const DailyProgressReportScreen({super.key});
@override
State<DailyProgressReportScreen> createState() =>
_DailyProgressReportScreenState();
}
class TaskChartData {
final String label;
final num value;
final Color color;
TaskChartData(this.label, this.value, this.color);
}
class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
with UIMixin {
final DailyTaskController dailyTaskController =
Get.put(DailyTaskController());
final PermissionController permissionController =
Get.find<PermissionController>();
final ProjectController projectController = Get.find<ProjectController>();
final ServiceController serviceController = Get.put(ServiceController());
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);
serviceController.fetchServices(initialProjectId);
}
// Update when project changes
ever<String>(projectController.selectedProjectId, (newProjectId) async {
if (newProjectId.isNotEmpty &&
newProjectId != dailyTaskController.selectedProjectId) {
dailyTaskController.selectedProjectId = newProjectId;
await dailyTaskController.fetchTaskData(newProjectId);
await serviceController.fetchServices(newProjectId);
dailyTaskController.update(['daily_progress_report_controller']);
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Daily Progress Report',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
),
),
body: SafeArea(
child: MyRefreshIndicator(
onRefresh: _refreshData,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: GetBuilder<DailyTaskController>(
init: dailyTaskController,
tag: 'daily_progress_report_controller',
builder: (controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
// --- ADD SERVICE SELECTOR HERE ---
Padding(
padding: MySpacing.x(10),
child: ServiceSelector(
controller: serviceController,
height: 40,
onSelectionChanged: (service) async {
final projectId =
dailyTaskController.selectedProjectId;
if (projectId?.isNotEmpty ?? false) {
await dailyTaskController.fetchTaskData(
projectId!,
serviceIds:
service != null ? [service.id] : null,
pageNumber: 1,
pageSize: 20,
);
}
},
),
),
_buildActionBar(),
Padding(
padding: MySpacing.x(8),
child: _buildDailyProgressReportTab(),
),
],
);
},
),
),
],
),
),
),
);
}
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>>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => DailyProgressReportFilter(
controller: dailyTaskController,
permissionController: permissionController,
),
);
if (result != null) {
final selectedProjectId = result['projectId'] as String?;
if (selectedProjectId != null &&
selectedProjectId != dailyTaskController.selectedProjectId) {
dailyTaskController.selectedProjectId = selectedProjectId;
await dailyTaskController.fetchTaskData(selectedProjectId);
dailyTaskController.update(['daily_progress_report_controller']);
}
}
}
Future<void> _refreshData() async {
final projectId = dailyTaskController.selectedProjectId;
if (projectId != null) {
try {
await dailyTaskController.fetchTaskData(projectId);
} catch (e) {
debugPrint('Error refreshing task data: $e');
}
}
}
void _showTeamMembersBottomSheet(List<dynamic> members) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
isDismissible: true,
enableDrag: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (context) {
return GestureDetector(
onTap: () {},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(12)),
),
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
'Team Members',
fontWeight: 600,
),
const SizedBox(height: 8),
const Divider(thickness: 1),
const SizedBox(height: 8),
...members.map((member) {
final firstName = member.firstName ?? 'Unnamed';
final lastName = member.lastName ?? 'User';
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Avatar(
firstName: firstName,
lastName: lastName,
size: 31,
),
title: MyText.bodyMedium(
'$firstName $lastName',
fontWeight: 600,
),
);
}),
const SizedBox(height: 8),
],
),
),
);
},
);
}
Widget _buildDailyProgressReportTab() {
return Obx(() {
final isLoading = dailyTaskController.isLoading.value;
final groupedTasks = dailyTaskController.groupedDailyTasks;
// Initial loading skeleton
if (isLoading && dailyTaskController.currentPage == 1) {
return SkeletonLoaders.dailyProgressReportSkeletonLoader();
}
// No tasks
if (groupedTasks.isEmpty) {
return Center(
child: MyText.bodySmall(
"No Progress Report Found",
fontWeight: 600,
),
);
}
final sortedDates = groupedTasks.keys.toList()
..sort((a, b) => b.compareTo(a));
// If only one date, make it expanded by default
if (sortedDates.length == 1 &&
!dailyTaskController.expandedDates.contains(sortedDates[0])) {
dailyTaskController.expandedDates.add(sortedDates[0]);
}
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.builder(
controller: _scrollController,
shrinkWrap: true,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: sortedDates.length + 1, // +1 for loading indicator
itemBuilder: (context, dateIndex) {
// Bottom loading indicator
if (dateIndex == sortedDates.length) {
return Obx(() => dailyTaskController.isLoadingMore.value
? const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
)
: const SizedBox.shrink());
}
final dateKey = sortedDates[dateIndex];
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,
),
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 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: 8),
child: MyContainer(
paddingAll: 12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium(activityName, fontWeight: 600),
const SizedBox(height: 2),
MyText.bodySmall(location, color: Colors.grey),
const SizedBox(height: 8),
GestureDetector(
onTap: () => _showTeamMembersBottomSheet(
task.teamMembers),
child: Row(
children: [
const Icon(Icons.group,
size: 18, color: Colors.blueAccent),
const SizedBox(width: 6),
MyText.bodyMedium('Team',
color: Colors.blueAccent,
fontWeight: 600),
],
),
),
const SizedBox(height: 8),
MyText.bodySmall(
"Completed: $completed / $planned",
fontWeight: 600,
color: Colors.black87,
),
const SizedBox(height: 6),
Stack(
children: [
Container(
height: 5,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(6),
),
),
FractionallySizedBox(
widthFactor: progress,
child: Container(
height: 5,
decoration: BoxDecoration(
color: progress >= 1.0
? Colors.green
: progress >= 0.5
? Colors.amber
: Colors.red,
borderRadius: BorderRadius.circular(6),
),
),
),
],
),
const SizedBox(height: 4),
MyText.bodySmall(
"${(progress * 100).toStringAsFixed(1)}%",
fontWeight: 500,
color: progress >= 1.0
? Colors.green[700]
: progress >= 0.5
? Colors.amber[800]
: Colors.red[700],
),
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if ((task.reportedDate == null ||
task.reportedDate
.toString()
.isEmpty) &&
permissionController.hasPermission(
Permissions.assignReportTask)) ...[
TaskActionButtons.reportButton(
context: context,
task: task,
completed: completed.toInt(),
refreshCallback: _refreshData,
),
const SizedBox(width: 4),
] else if (task.approvedBy == null &&
permissionController.hasPermission(
Permissions.approveTask)) ...[
TaskActionButtons.reportActionButton(
context: context,
task: task,
parentTaskID: parentTaskID,
workAreaId: workAreaId.toString(),
activityId: activityId.toString(),
completed: completed.toInt(),
refreshCallback: _refreshData,
),
const SizedBox(width: 5),
],
TaskActionButtons.commentButton(
context: context,
task: task,
parentTaskID: parentTaskID,
workAreaId: workAreaId.toString(),
activityId: activityId.toString(),
refreshCallback: _refreshData,
),
],
),
),
],
),
),
);
}).toList(),
);
})
],
);
},
),
);
});
}
}

View File

@ -0,0 +1,562 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/theme/app_theme.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/utils/my_shadow.dart';
import 'package:marco/helpers/widgets/my_card.dart';
import 'package:marco/helpers/widgets/my_container.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/task_planning/daily_task_controller.dart';
import 'package:marco/model/dailyTaskPlanning/daily_progress_report_filter.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/model/dailyTaskPlanning/task_action_buttons.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';
class DailyProgressReportScreen extends StatefulWidget {
const DailyProgressReportScreen({super.key});
@override
State<DailyProgressReportScreen> createState() =>
_DailyProgressReportScreenState();
}
class TaskChartData {
final String label;
final num value;
final Color color;
TaskChartData(this.label, this.value, this.color);
}
class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
with UIMixin {
final DailyTaskController dailyTaskController =
Get.put(DailyTaskController());
final PermissionController 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);
}
// 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
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Daily Progress Report',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
),
),
body: SafeArea(
child: MyRefreshIndicator(
onRefresh: _refreshData,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: GetBuilder<DailyTaskController>(
init: dailyTaskController,
tag: 'daily_progress_report_controller',
builder: (controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
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(),
),
],
);
},
),
),
],
),
),
),
);
}
Future<void> _openFilterSheet() async {
// Fetch filter data first
if (dailyTaskController.taskFilterData == null) {
await dailyTaskController
.fetchTaskFilter(dailyTaskController.selectedProjectId ?? '');
}
final result = await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => DailyTaskFilterBottomSheet(
controller: dailyTaskController,
),
);
if (result != null) {
final selectedProjectId = result['projectId'] as String?;
if (selectedProjectId != null &&
selectedProjectId != dailyTaskController.selectedProjectId) {
dailyTaskController.selectedProjectId = selectedProjectId;
await dailyTaskController.fetchTaskData(selectedProjectId);
dailyTaskController.update(['daily_progress_report_controller']);
}
}
}
Future<void> _refreshData() async {
final projectId = dailyTaskController.selectedProjectId;
if (projectId != null) {
try {
await dailyTaskController.fetchTaskData(projectId);
} catch (e) {
debugPrint('Error refreshing task data: $e');
}
}
}
void _showTeamMembersBottomSheet(List<dynamic> members) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
isDismissible: true,
enableDrag: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (context) {
return GestureDetector(
onTap: () {},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(12)),
),
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
'Team Members',
fontWeight: 600,
),
const SizedBox(height: 8),
const Divider(thickness: 1),
const SizedBox(height: 8),
...members.map((member) {
final firstName = member.firstName ?? 'Unnamed';
final lastName = member.lastName ?? 'User';
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Avatar(
firstName: firstName,
lastName: lastName,
size: 31,
),
title: MyText.bodyMedium(
'$firstName $lastName',
fontWeight: 600,
),
);
}),
const SizedBox(height: 8),
],
),
),
);
},
);
}
Widget _buildDailyProgressReportTab() {
return Obx(() {
final isLoading = dailyTaskController.isLoading.value;
final groupedTasks = dailyTaskController.groupedDailyTasks;
// 🟡 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(
"No Progress Report Found",
fontWeight: 600,
),
);
}
// 🔽 Sort all date keys by descending (latest first)
final sortedDates = groupedTasks.keys.toList()
..sort((a, b) => b.compareTo(a));
// 🔹 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 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,
)),
],
),
),
),
// 🔽 Task List (expandable)
Obx(() {
if (!dailyTaskController.expandedDates
.contains(dateKey)) {
return const SizedBox.shrink();
}
return Column(
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),
child: Row(
children: [
const Icon(Icons.group,
size: 18, color: Colors.blueAccent),
const SizedBox(width: 6),
MyText.bodyMedium(
'Team',
color: Colors.blueAccent,
fontWeight: 600,
),
],
),
),
const SizedBox(height: 8),
// 📊 Progress info
MyText.bodySmall(
"Completed: $completed / $planned",
fontWeight: 600,
color: Colors.black87,
),
const SizedBox(height: 6),
Stack(
children: [
Container(
height: 5,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius:
BorderRadius.circular(6),
),
),
FractionallySizedBox(
widthFactor: progress,
child: Container(
height: 5,
decoration: BoxDecoration(
color: progress >= 1.0
? Colors.green
: progress >= 0.5
? Colors.amber
: Colors.red,
borderRadius:
BorderRadius.circular(6),
),
),
),
],
),
const SizedBox(height: 4),
MyText.bodySmall(
"${(progress * 100).toStringAsFixed(1)}%",
fontWeight: 500,
color: progress >= 1.0
? Colors.green[700]
: progress >= 0.5
? Colors.amber[800]
: Colors.red[700],
),
const SizedBox(height: 12),
// 🎯 Action Buttons
SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(),
primary: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if ((task.reportedDate == null ||
task.reportedDate
.toString()
.isEmpty) &&
permissionController.hasPermission(
Permissions
.assignReportTask)) ...[
TaskActionButtons.reportButton(
context: context,
task: task,
completed: completed.toInt(),
refreshCallback: _refreshData,
),
const SizedBox(width: 4),
] else if (task.approvedBy == null &&
permissionController.hasPermission(
Permissions.approveTask)) ...[
TaskActionButtons.reportActionButton(
context: context,
task: task,
parentTaskID: parentTaskID,
workAreaId: workAreaId.toString(),
activityId: activityId.toString(),
completed: completed.toInt(),
refreshCallback: _refreshData,
),
const SizedBox(width: 5),
],
TaskActionButtons.commentButton(
context: context,
task: task,
parentTaskID: parentTaskID,
workAreaId: workAreaId.toString(),
activityId: activityId.toString(),
refreshCallback: _refreshData,
),
],
),
),
],
),
),
);
}).toList(),
);
}),
],
),
),
);
}),
// 🔻 Loading More Indicator
Obx(() => dailyTaskController.isLoadingMore.value
? const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
)
: const SizedBox.shrink()),
],
);
});
}
}

View File

@ -40,16 +40,16 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final projectId = projectController.selectedProjectId.value; final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) { if (projectId.isNotEmpty) {
dailyTaskPlanningController.fetchTaskData(projectId); dailyTaskPlanningController.fetchTaskData(projectId);
serviceController.fetchServices(projectId); // <-- Fetch services here serviceController.fetchServices(projectId);
} }
// Whenever project changes, fetch tasks & services
ever<String>( ever<String>(
projectController.selectedProjectId, projectController.selectedProjectId,
(newProjectId) { (newProjectId) {
if (newProjectId.isNotEmpty) { if (newProjectId.isNotEmpty) {
dailyTaskPlanningController.fetchTaskData(newProjectId); dailyTaskPlanningController.fetchTaskData(newProjectId);
serviceController serviceController.fetchServices(newProjectId);
.fetchServices(newProjectId);
} }
}, },
); );
@ -123,18 +123,19 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final projectId = projectController.selectedProjectId.value; final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) { if (projectId.isNotEmpty) {
try { try {
await dailyTaskPlanningController.fetchTaskData(projectId); await dailyTaskPlanningController.fetchTaskData(
projectId,
serviceId: serviceController.selectedService?.id,
);
} catch (e) { } catch (e) {
debugPrint('Error refreshing task data: ${e.toString()}'); debugPrint('Error refreshing task data: ${e.toString()}');
} }
} }
}, },
child: SingleChildScrollView( child: SingleChildScrollView(
physics: physics: const AlwaysScrollableScrollPhysics(),
const AlwaysScrollableScrollPhysics(), // <-- always allow drag
padding: MySpacing.x(0), padding: MySpacing.x(0),
child: ConstrainedBox( child: ConstrainedBox(
// <-- ensures full screen height
constraints: BoxConstraints( constraints: BoxConstraints(
minHeight: MediaQuery.of(context).size.height - minHeight: MediaQuery.of(context).size.height -
kToolbarHeight - kToolbarHeight -
@ -159,8 +160,8 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
if (projectId.isNotEmpty) { if (projectId.isNotEmpty) {
await dailyTaskPlanningController.fetchTaskData( await dailyTaskPlanningController.fetchTaskData(
projectId, projectId,
// serviceId: service serviceId:
// ?.id, service?.id, // <-- pass selected service
); );
} }
}, },
@ -184,7 +185,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
Widget dailyProgressReportTab() { Widget dailyProgressReportTab() {
return Obx(() { return Obx(() {
final isLoading = dailyTaskPlanningController.isLoading.value; final isLoading = dailyTaskPlanningController.isFetchingTasks.value;
final dailyTasks = dailyTaskPlanningController.dailyTasks; final dailyTasks = dailyTaskPlanningController.dailyTasks;
if (isLoading) { if (isLoading) {
@ -288,7 +289,6 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final validWorkAreas = floor.workAreas final validWorkAreas = floor.workAreas
.where((area) => area.workItems.isNotEmpty); .where((area) => area.workItems.isNotEmpty);
// For each valid work area, return a Floor+WorkArea ExpansionTile
return validWorkAreas.map((area) { return validWorkAreas.map((area) {
final floorWorkAreaKey = final floorWorkAreaKey =
"${buildingKey}_${floor.floorName}_${area.areaName}"; "${buildingKey}_${floor.floorName}_${area.areaName}";
@ -302,6 +302,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final totalProgress = totalPlanned == 0 final totalProgress = totalPlanned == 0
? 0.0 ? 0.0
: (totalCompleted / totalPlanned).clamp(0.0, 1.0); : (totalCompleted / totalPlanned).clamp(0.0, 1.0);
return ExpansionTile( return ExpansionTile(
onExpansionChanged: (expanded) { onExpansionChanged: (expanded) {
setMainState(() { setMainState(() {
@ -353,7 +354,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
percent: totalProgress, percent: totalProgress,
center: Text( center: Text(
"${(totalProgress * 100).toStringAsFixed(0)}%", "${(totalProgress * 100).toStringAsFixed(0)}%",
style: TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 10.0, fontSize: 10.0,
), ),
@ -439,7 +440,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
permissionController.hasPermission( permissionController.hasPermission(
Permissions.assignReportTask)) Permissions.assignReportTask))
IconButton( IconButton(
icon: Icon( icon: const Icon(
Icons.person_add_alt_1_rounded, Icons.person_add_alt_1_rounded,
color: color:
Color.fromARGB(255, 46, 161, 233), Color.fromARGB(255, 46, 161, 233),
@ -503,7 +504,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
), ),
], ],
), ),
SizedBox(height: 4), const SizedBox(height: 4),
MyText.bodySmall( MyText.bodySmall(
"${(progress * 100).toStringAsFixed(1)}%", "${(progress * 100).toStringAsFixed(1)}%",
fontWeight: 500, fontWeight: 500,

View File

@ -2,10 +2,12 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/api_endpoints.dart'; import 'package:marco/helpers/services/api_endpoints.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/images.dart'; import 'package:marco/images.dart';
import 'package:marco/controller/tenant/tenant_selection_controller.dart'; import 'package:marco/controller/tenant/tenant_selection_controller.dart';
import 'package:marco/view/splash_screen.dart';
class TenantSelectionScreen extends StatefulWidget { class TenantSelectionScreen extends StatefulWidget {
const TenantSelectionScreen({super.key}); const TenantSelectionScreen({super.key});
@ -20,24 +22,23 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
late final AnimationController _logoAnimController; late final AnimationController _logoAnimController;
late final Animation<double> _logoAnimation; late final Animation<double> _logoAnimation;
final bool _isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage"); final bool _isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage");
bool _isLoading = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = Get.put(TenantSelectionController()); _controller = Get.put(TenantSelectionController());
_logoAnimController = AnimationController( _logoAnimController = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 800), duration: const Duration(milliseconds: 800),
); );
_logoAnimation = CurvedAnimation( _logoAnimation = CurvedAnimation(
parent: _logoAnimController, parent: _logoAnimController,
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
); );
_logoAnimController.forward();
// 🔥 Tell controller this is tenant selection screen _logoAnimController.forward();
_controller.loadTenants(fromTenantSelectionScreen: true);
} }
@override @override
@ -48,61 +49,66 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
} }
Future<void> _onTenantSelected(String tenantId) async { Future<void> _onTenantSelected(String tenantId) async {
setState(() => _isLoading = true);
await _controller.onTenantSelected(tenantId); await _controller.onTenantSelected(tenantId);
setState(() => _isLoading = false);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Obx(() {
body: Stack( // Splash screen for auto-selection
children: [ if (_controller.isAutoSelecting.value) {
_RedWaveBackground(brandRed: contentTheme.brandRed), return const SplashScreen();
SafeArea( }
child: Center(
child: Column( return Scaffold(
children: [ body: Stack(
const SizedBox(height: 24), children: [
_AnimatedLogo(animation: _logoAnimation), _RedWaveBackground(brandRed: contentTheme.brandRed),
const SizedBox(height: 8), SafeArea(
Expanded( child: Center(
child: SingleChildScrollView( child: Column(
padding: const EdgeInsets.symmetric(horizontal: 24), children: [
child: Center( const SizedBox(height: 24),
child: ConstrainedBox( _AnimatedLogo(animation: _logoAnimation),
constraints: const BoxConstraints(maxWidth: 420), const SizedBox(height: 8),
child: Column( Expanded(
children: [ child: SingleChildScrollView(
const SizedBox(height: 12), padding: const EdgeInsets.symmetric(horizontal: 24),
const _WelcomeTexts(), child: Center(
if (_isBetaEnvironment) ...[ child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
children: [
const SizedBox(height: 12), const SizedBox(height: 12),
const _BetaBadge(), const _WelcomeTexts(),
if (_isBetaEnvironment) ...[
const SizedBox(height: 12),
const _BetaBadge(),
],
const SizedBox(height: 36),
TenantCardList(
controller: _controller,
isLoading: _controller.isLoading.value,
onTenantSelected: _onTenantSelected,
),
], ],
const SizedBox(height: 36), ),
// Tenant list directly reacts to controller
TenantCardList(
controller: _controller,
isLoading: _isLoading,
onTenantSelected: _onTenantSelected,
),
],
), ),
), ),
), ),
), ),
), ],
], ),
), ),
), ),
), ],
], ),
), );
); });
} }
} }
/// Animated Logo Widget
class _AnimatedLogo extends StatelessWidget { class _AnimatedLogo extends StatelessWidget {
final Animation<double> animation; final Animation<double> animation;
const _AnimatedLogo({required this.animation}); const _AnimatedLogo({required this.animation});
@ -132,6 +138,7 @@ class _AnimatedLogo extends StatelessWidget {
} }
} }
/// Welcome Texts
class _WelcomeTexts extends StatelessWidget { class _WelcomeTexts extends StatelessWidget {
const _WelcomeTexts(); const _WelcomeTexts();
@ -148,7 +155,7 @@ class _WelcomeTexts extends StatelessWidget {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
MyText( MyText(
"Please select which dashboard you want to explore!.", "Please select which dashboard you want to explore!",
fontSize: 14, fontSize: 14,
color: Colors.black54, color: Colors.black54,
textAlign: TextAlign.center, textAlign: TextAlign.center,
@ -158,6 +165,7 @@ class _WelcomeTexts extends StatelessWidget {
} }
} }
/// Beta Badge
class _BetaBadge extends StatelessWidget { class _BetaBadge extends StatelessWidget {
const _BetaBadge(); const _BetaBadge();
@ -179,6 +187,7 @@ class _BetaBadge extends StatelessWidget {
} }
} }
/// Tenant Card List
class TenantCardList extends StatelessWidget { class TenantCardList extends StatelessWidget {
final TenantSelectionController controller; final TenantSelectionController controller;
final bool isLoading; final bool isLoading;
@ -194,53 +203,52 @@ class TenantCardList extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
if (controller.isLoading.value || isLoading) { if (controller.isLoading.value || isLoading) {
return const Center( return const Center(child: CircularProgressIndicator(strokeWidth: 2));
child: CircularProgressIndicator(strokeWidth: 2),
);
} }
if (controller.tenants.isEmpty) {
return Center( final hasTenants = controller.tenants.isNotEmpty;
child: MyText(
"No dashboards available for your account.", return Column(
fontSize: 14, crossAxisAlignment: CrossAxisAlignment.center,
color: Colors.black54, children: [
textAlign: TextAlign.center, if (!hasTenants) ...[
), MyText(
); "No dashboards available for your account.",
} fontSize: 14,
if (controller.tenants.length == 1) { color: Colors.black54,
return const SizedBox.shrink(); textAlign: TextAlign.center,
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
...controller.tenants.map(
(tenant) => _TenantCard(
tenant: tenant,
onTap: () => onTenantSelected(tenant.id),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextButton.icon(
onPressed: () => Get.back(),
icon: const Icon(Icons.arrow_back,
size: 20, color: Colors.redAccent),
label: MyText(
'Back to Login',
color: Colors.red,
fontWeight: 600,
fontSize: 14,
),
),
], ],
),
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 { class _TenantCard extends StatelessWidget {
final dynamic tenant; final dynamic tenant;
final VoidCallback onTap; final VoidCallback onTap;
@ -253,9 +261,7 @@ class _TenantCard extends StatelessWidget {
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
child: Card( child: Card(
elevation: 3, elevation: 3,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
borderRadius: BorderRadius.circular(5),
),
margin: const EdgeInsets.only(bottom: 20), margin: const EdgeInsets.only(bottom: 20),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -291,11 +297,7 @@ class _TenantCard extends StatelessWidget {
], ],
), ),
), ),
Icon( const Icon(Icons.arrow_forward_ios, size: 24, color: Colors.red),
Icons.arrow_forward_ios,
size: 24,
color: Colors.red,
),
], ],
), ),
), ),
@ -304,6 +306,7 @@ class _TenantCard extends StatelessWidget {
} }
} }
/// Tenant Logo (supports base64 and URL)
class TenantLogo extends StatelessWidget { class TenantLogo extends StatelessWidget {
final String? logoImage; final String? logoImage;
const TenantLogo({required this.logoImage}); const TenantLogo({required this.logoImage});
@ -311,9 +314,7 @@ class TenantLogo extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (logoImage == null || logoImage!.isEmpty) { if (logoImage == null || logoImage!.isEmpty) {
return Center( return Center(child: Icon(Icons.business, color: Colors.grey.shade600));
child: Icon(Icons.business, color: Colors.grey.shade600),
);
} }
if (logoImage!.startsWith("data:image")) { if (logoImage!.startsWith("data:image")) {
try { try {
@ -321,9 +322,7 @@ class TenantLogo extends StatelessWidget {
final bytes = base64Decode(base64Str); final bytes = base64Decode(base64Str);
return Image.memory(bytes, fit: BoxFit.cover); return Image.memory(bytes, fit: BoxFit.cover);
} catch (_) { } catch (_) {
return Center( return Center(child: Icon(Icons.business, color: Colors.grey.shade600));
child: Icon(Icons.business, color: Colors.grey.shade600),
);
} }
} else { } else {
return Image.network( return Image.network(
@ -337,6 +336,7 @@ class TenantLogo extends StatelessWidget {
} }
} }
/// Red Wave Background
class _RedWaveBackground extends StatelessWidget { class _RedWaveBackground extends StatelessWidget {
final Color brandRed; final Color brandRed;
const _RedWaveBackground({required this.brandRed}); const _RedWaveBackground({required this.brandRed});
@ -352,7 +352,6 @@ class _RedWaveBackground extends StatelessWidget {
class _WavePainter extends CustomPainter { class _WavePainter extends CustomPainter {
final Color brandRed; final Color brandRed;
_WavePainter(this.brandRed); _WavePainter(this.brandRed);
@override @override
@ -366,8 +365,8 @@ class _WavePainter extends CustomPainter {
final path1 = Path() final path1 = Path()
..moveTo(0, size.height * 0.2) ..moveTo(0, size.height * 0.2)
..quadraticBezierTo(size.width * 0.25, size.height * 0.05, ..quadraticBezierTo(
size.width * 0.5, size.height * 0.15) size.width * 0.25, size.height * 0.05, size.width * 0.5, size.height * 0.15)
..quadraticBezierTo( ..quadraticBezierTo(
size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1) size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1)
..lineTo(size.width, 0) ..lineTo(size.width, 0)
@ -378,8 +377,7 @@ class _WavePainter extends CustomPainter {
final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15); final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15);
final path2 = Path() final path2 = Path()
..moveTo(0, size.height * 0.25) ..moveTo(0, size.height * 0.25)
..quadraticBezierTo( ..quadraticBezierTo(size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2)
size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2)
..lineTo(size.width, 0) ..lineTo(size.width, 0)
..lineTo(0, 0) ..lineTo(0, 0)
..close(); ..close();