diff --git a/android/gradle.properties b/android/gradle.properties index 2597170..84044a9 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,3 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -android.enableJetifier=true +android.enableJetifier=false diff --git a/lib/controller/auth/forgot_password_controller.dart b/lib/controller/auth/forgot_password_controller.dart index 527f879..8d544d7 100644 --- a/lib/controller/auth/forgot_password_controller.dart +++ b/lib/controller/auth/forgot_password_controller.dart @@ -32,7 +32,7 @@ class ForgotPasswordController extends MyController { final email = data['email']?.toString() ?? ''; try { - logSafe("Forgot password requested for: $email", sensitive: true); + logSafe("Forgot password requested for: $email", ); final result = await AuthService.forgotPassword(email); @@ -50,7 +50,7 @@ class ForgotPasswordController extends MyController { message: errorMessage, type: SnackbarType.error, ); - logSafe("Failed to send reset password email for $email: $errorMessage", level: LogLevel.warning, sensitive: true); + logSafe("Failed to send reset password email for $email: $errorMessage", level: LogLevel.warning, ); } } catch (e, stacktrace) { logSafe("Error during forgot password", level: LogLevel.error, error: e, stackTrace: stacktrace); diff --git a/lib/controller/auth/login_controller.dart b/lib/controller/auth/login_controller.dart index af4a62a..cf0aad3 100644 --- a/lib/controller/auth/login_controller.dart +++ b/lib/controller/auth/login_controller.dart @@ -55,12 +55,12 @@ class LoginController extends MyController { try { final loginData = basicValidator.getData(); - logSafe("Attempting login for user: ${loginData['username']}", sensitive: true); + logSafe("Attempting login for user: ${loginData['username']}", ); final errors = await AuthService.loginUser(loginData); if (errors != null) { - logSafe("Login failed for user: ${loginData['username']} with errors: $errors", level: LogLevel.warning, sensitive: true); + logSafe("Login failed for user: ${loginData['username']} with errors: $errors", level: LogLevel.warning, ); showAppSnackbar( title: "Login Failed", @@ -73,7 +73,7 @@ class LoginController extends MyController { basicValidator.clearErrors(); } else { await _handleRememberMe(); - logSafe("Login successful for user: ${loginData['username']}", sensitive: true); + logSafe("Login successful for user: ${loginData['username']}", ); Get.toNamed('/home'); } } catch (e, stacktrace) { diff --git a/lib/controller/auth/mpin_controller.dart b/lib/controller/auth/mpin_controller.dart index 31ac669..cde5bc0 100644 --- a/lib/controller/auth/mpin_controller.dart +++ b/lib/controller/auth/mpin_controller.dart @@ -29,7 +29,7 @@ class MPINController extends GetxController { } void onDigitChanged(String value, int index, {bool isRetype = false}) { - logSafe("onDigitChanged -> index: $index, value: $value, isRetype: $isRetype", sensitive: true); + logSafe("onDigitChanged -> index: $index, value: $value, isRetype: $isRetype", ); final nodes = isRetype ? retypeFocusNodes : focusNodes; if (value.isNotEmpty && index < 5) { nodes[index + 1].requestFocus(); @@ -47,7 +47,7 @@ class MPINController extends GetxController { } final enteredMPIN = digitControllers.map((c) => c.text).join(); - logSafe("Entered MPIN: $enteredMPIN", sensitive: true); + logSafe("Entered MPIN: $enteredMPIN", ); if (enteredMPIN.length < 6) { _showError("Please enter all 6 digits."); @@ -56,7 +56,7 @@ class MPINController extends GetxController { if (isNewUser.value) { final retypeMPIN = retypeControllers.map((c) => c.text).join(); - logSafe("Retyped MPIN: $retypeMPIN", sensitive: true); + logSafe("Retyped MPIN: $retypeMPIN", ); if (retypeMPIN.length < 6) { _showError("Please enter all 6 digits in Retype MPIN."); @@ -177,7 +177,7 @@ class MPINController extends GetxController { return false; } - logSafe("Calling AuthService.generateMpin for employeeId: $employeeId", sensitive: true); + logSafe("Calling AuthService.generateMpin for employeeId: $employeeId", ); final response = await AuthService.generateMpin( employeeId: employeeId, @@ -222,7 +222,7 @@ class MPINController extends GetxController { logSafe("verifyMPIN triggered"); final enteredMPIN = digitControllers.map((c) => c.text).join(); - logSafe("Entered MPIN: $enteredMPIN", sensitive: true); + logSafe("Entered MPIN: $enteredMPIN", ); if (enteredMPIN.length < 6) { _showError("Please enter all 6 digits."); diff --git a/lib/controller/auth/otp_controller.dart b/lib/controller/auth/otp_controller.dart index 4f18c1e..43c76ac 100644 --- a/lib/controller/auth/otp_controller.dart +++ b/lib/controller/auth/otp_controller.dart @@ -25,6 +25,7 @@ class OTPController extends GetxController { void onInit() { super.onInit(); timer.value = 0; + _loadSavedEmail(); logSafe("[OTPController] Initialized"); } @@ -53,7 +54,6 @@ class OTPController extends GetxController { "[OTPController] OTP send failed", level: LogLevel.warning, error: result['error'], - ); showAppSnackbar( title: "Error", @@ -85,6 +85,7 @@ class OTPController extends GetxController { if (success) { email.value = userEmail; isOTPSent.value = true; + await _saveEmailIfRemembered(userEmail); _startTimer(); _clearOTPFields(); } @@ -144,7 +145,7 @@ class OTPController extends GetxController { Get.offAllNamed('/home'); } else { final error = result['error'] ?? "Failed to verify OTP"; - logSafe("[OTPController] OTP verification failed", level: LogLevel.warning, error: error, sensitive: true); + logSafe("[OTPController] OTP verification failed", level: LogLevel.warning, error: error); showAppSnackbar( title: "Error", message: error, @@ -189,10 +190,32 @@ class OTPController extends GetxController { for (final node in focusNodes) { node.unfocus(); } + + // Optionally remove saved email + LocalStorage.removeToken('otp_email'); } bool _validateEmail(String email) { final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$'); return regex.hasMatch(email); } + + /// Save email to local storage if "remember me" is set + Future _saveEmailIfRemembered(String email) async { + final remember = LocalStorage.getBool('remember_me') ?? false; + if (remember) { + await LocalStorage.setToken('otp_email', email); + } + } + + /// Load email from local storage if "remember me" is true + Future _loadSavedEmail() async { + final remember = LocalStorage.getBool('remember_me') ?? false; + if (remember) { + final savedEmail = LocalStorage.getToken('otp_email') ?? ''; + emailController.text = savedEmail; + email.value = savedEmail; + logSafe("[OTPController] Loaded saved email from local storage: $savedEmail"); + } + } } diff --git a/lib/controller/dashboard/add_employee_controller.dart b/lib/controller/dashboard/add_employee_controller.dart index 250e4cd..6b0e5c0 100644 --- a/lib/controller/dashboard/add_employee_controller.dart +++ b/lib/controller/dashboard/add_employee_controller.dart @@ -146,7 +146,7 @@ class AddEmployeeController extends MyController { gender: selectedGender!.name, jobRoleId: selectedRoleId!, ); - +logSafe("Response: $response"); if (response == true) { logSafe("Employee created successfully."); showAppSnackbar( diff --git a/lib/controller/dashboard/attendance_screen_controller.dart b/lib/controller/dashboard/attendance_screen_controller.dart index 45d227d..f477be9 100644 --- a/lib/controller/dashboard/attendance_screen_controller.dart +++ b/lib/controller/dashboard/attendance_screen_controller.dart @@ -197,7 +197,7 @@ class AttendanceController extends GetxController { textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom(foregroundColor: Colors.teal), ), - dialogTheme: const DialogTheme(backgroundColor: Colors.white), + dialogTheme: DialogThemeData(backgroundColor: Colors.white), ), child: child!, ), diff --git a/lib/controller/dashboard/dashboard_controller.dart b/lib/controller/dashboard/dashboard_controller.dart index b22c0bf..2ead7d1 100644 --- a/lib/controller/dashboard/dashboard_controller.dart +++ b/lib/controller/dashboard/dashboard_controller.dart @@ -20,7 +20,7 @@ class DashboardController extends GetxController { logSafe( 'DashboardController initialized with project ID: ${projectController.selectedProjectId.value}', level: LogLevel.info, - sensitive: true, + ); if (projectController.selectedProjectId.value.isNotEmpty) { @@ -30,7 +30,7 @@ class DashboardController extends GetxController { // React to project change ever(projectController.selectedProjectId, (id) { if (id.isNotEmpty) { - logSafe('Project changed to $id, fetching attendance', level: LogLevel.info, sensitive: true); + logSafe('Project changed to $id, fetching attendance', level: LogLevel.info, ); fetchRoleWiseAttendance(); } }); diff --git a/lib/controller/dashboard/employees_screen_controller.dart b/lib/controller/dashboard/employees_screen_controller.dart index ca91a91..273e080 100644 --- a/lib/controller/dashboard/employees_screen_controller.dart +++ b/lib/controller/dashboard/employees_screen_controller.dart @@ -106,15 +106,15 @@ class EmployeesScreenController extends GetxController { logSafe( "Employees fetched: ${employees.length} for project $projectId", level: LogLevel.info, - sensitive: true, + ); }, onEmpty: () { employees.clear(); - logSafe("No employees found for project $projectId.", level: LogLevel.warning, sensitive: true); + logSafe("No employees found for project $projectId.", level: LogLevel.warning, ); }, onError: (e) { - logSafe("Error fetching employees for project $projectId", level: LogLevel.error, error: e, sensitive: true); + logSafe("Error fetching employees for project $projectId", level: LogLevel.error, error: e, ); }, ); @@ -131,15 +131,15 @@ class EmployeesScreenController extends GetxController { () => ApiService.getEmployeeDetails(employeeId), onSuccess: (data) { selectedEmployeeDetails.value = EmployeeDetailsModel.fromJson(data); - logSafe("Employee details loaded for $employeeId", level: LogLevel.info, sensitive: true); + logSafe("Employee details loaded for $employeeId", level: LogLevel.info, ); }, onEmpty: () { selectedEmployeeDetails.value = null; - logSafe("No employee details found for $employeeId", level: LogLevel.warning, sensitive: true); + logSafe("No employee details found for $employeeId", level: LogLevel.warning, ); }, onError: (e) { selectedEmployeeDetails.value = null; - logSafe("Error fetching employee details for $employeeId", level: LogLevel.error, error: e, sensitive: true); + logSafe("Error fetching employee details for $employeeId", level: LogLevel.error, error: e, ); }, ); diff --git a/lib/controller/directory/add_comment_controller.dart b/lib/controller/directory/add_comment_controller.dart new file mode 100644 index 0000000..beebff5 --- /dev/null +++ b/lib/controller/directory/add_comment_controller.dart @@ -0,0 +1,73 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/controller/directory/directory_controller.dart'; + +class AddCommentController extends GetxController { + final String contactId; + + AddCommentController({required this.contactId}); + + final RxString note = ''.obs; + final RxBool isSubmitting = false.obs; + + Future submitComment() async { + if (note.value.trim().isEmpty) { + showAppSnackbar( + title: "Missing Comment", + message: "Please enter a comment before submitting.", + type: SnackbarType.warning, + ); + return; + } + + isSubmitting.value = true; + + try { + logSafe("Submitting comment for contactId: $contactId"); + + final success = await ApiService.addContactComment( + note.value.trim(), + contactId, + ); + + if (success) { + logSafe("Comment added successfully."); + + // Refresh UI + final directoryController = Get.find(); + await directoryController.fetchCommentsForContact(contactId); + + Get.back(result: true); + + showAppSnackbar( + title: "Comment Added", + message: "Your comment has been successfully added.", + type: SnackbarType.success, + ); + } else { + logSafe("Comment submission failed", level: LogLevel.error); + showAppSnackbar( + title: "Submission Failed", + message: "Unable to add the comment. Please try again later.", + type: SnackbarType.error, + ); + } + } catch (e) { + logSafe("Error while submitting comment: $e", level: LogLevel.error); + showAppSnackbar( + title: "Unexpected Error", + message: "Something went wrong while adding your comment.", + type: SnackbarType.error, + ); + } finally { + isSubmitting.value = false; + } + } + + void updateNote(String value) { + note.value = value; + logSafe("Note updated: ${value.trim()}"); + } +} diff --git a/lib/controller/directory/add_contact_controller.dart b/lib/controller/directory/add_contact_controller.dart new file mode 100644 index 0000000..bcc1abc --- /dev/null +++ b/lib/controller/directory/add_contact_controller.dart @@ -0,0 +1,356 @@ +// Updated AddContactController to support multiple emails and phones + +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; + +class AddContactController extends GetxController { + final RxList categories = [].obs; + final RxList buckets = [].obs; + final RxList globalProjects = [].obs; + final RxList tags = [].obs; + + final RxString selectedCategory = ''.obs; + final RxString selectedBucket = ''.obs; + final RxString selectedProject = ''.obs; + + final RxList enteredTags = [].obs; + final RxList filteredSuggestions = [].obs; + final RxList organizationNames = [].obs; + final RxList filteredOrgSuggestions = [].obs; + + final RxMap categoriesMap = {}.obs; + final RxMap bucketsMap = {}.obs; + final RxMap projectsMap = {}.obs; + final RxMap tagsMap = {}.obs; + final RxBool isInitialized = false.obs; + final RxList selectedProjects = [].obs; + + @override + void onInit() { + super.onInit(); + logSafe("AddContactController initialized", level: LogLevel.debug); + fetchInitialData(); + } + + Future fetchInitialData() async { + logSafe("Fetching initial dropdown data", level: LogLevel.debug); + await Future.wait([ + fetchBuckets(), + fetchGlobalProjects(), + fetchTags(), + fetchCategories(), + fetchOrganizationNames(), + ]); + + // ✅ Mark initialization as done + isInitialized.value = true; + } + + void resetForm() { + selectedCategory.value = ''; + selectedProject.value = ''; + selectedBucket.value = ''; + enteredTags.clear(); + filteredSuggestions.clear(); + filteredOrgSuggestions.clear(); + selectedProjects.clear(); + } + + Future fetchBuckets() async { + try { + final response = await ApiService.getContactBucketList(); + if (response != null && response['data'] is List) { + final names = []; + for (var item in response['data']) { + if (item['name'] != null && item['id'] != null) { + bucketsMap[item['name']] = item['id'].toString(); + names.add(item['name']); + } + } + buckets.assignAll(names); + logSafe("Fetched \${names.length} buckets"); + } + } catch (e) { + logSafe("Failed to fetch buckets: \$e", level: LogLevel.error); + } + } + + Future fetchOrganizationNames() async { + try { + final orgs = await ApiService.getOrganizationList(); + organizationNames.assignAll(orgs); + logSafe("Fetched \${orgs.length} organization names"); + } catch (e) { + logSafe("Failed to load organization names: \$e", level: LogLevel.error); + } + } + + Future submitContact({ + String? id, + required String name, + required String organization, + required List> emails, + required List> phones, + required String address, + required String description, + }) async { + final categoryId = categoriesMap[selectedCategory.value]; + final bucketId = bucketsMap[selectedBucket.value]; + final projectIds = selectedProjects + .map((name) => projectsMap[name]) + .whereType() + .toList(); + + // === Per-field Validation with Specific Messages === + if (name.trim().isEmpty) { + showAppSnackbar( + title: "Missing Name", + message: "Please enter the contact name.", + type: SnackbarType.warning, + ); + return; + } + + if (organization.trim().isEmpty) { + showAppSnackbar( + title: "Missing Organization", + message: "Please enter the organization name.", + type: SnackbarType.warning, + ); + return; + } + + if (emails.isEmpty) { + showAppSnackbar( + title: "Missing Email", + message: "Please add at least one email.", + type: SnackbarType.warning, + ); + return; + } + + if (phones.isEmpty) { + showAppSnackbar( + title: "Missing Phone Number", + message: "Please add at least one phone number.", + type: SnackbarType.warning, + ); + return; + } + + if (address.trim().isEmpty) { + showAppSnackbar( + title: "Missing Address", + message: "Please enter the address.", + type: SnackbarType.warning, + ); + return; + } + + if (description.trim().isEmpty) { + showAppSnackbar( + title: "Missing Description", + message: "Please enter a description.", + type: SnackbarType.warning, + ); + return; + } + + if (selectedCategory.value.trim().isEmpty || categoryId == null) { + showAppSnackbar( + title: "Missing Category", + message: "Please select a contact category.", + type: SnackbarType.warning, + ); + return; + } + + if (selectedBucket.value.trim().isEmpty || bucketId == null) { + showAppSnackbar( + title: "Missing Bucket", + message: "Please select a bucket.", + type: SnackbarType.warning, + ); + return; + } + + if (selectedProjects.isEmpty || projectIds.isEmpty) { + showAppSnackbar( + title: "Missing Projects", + message: "Please select at least one project.", + type: SnackbarType.warning, + ); + return; + } + + if (enteredTags.isEmpty) { + showAppSnackbar( + title: "Missing Tags", + message: "Please enter at least one tag.", + type: SnackbarType.warning, + ); + return; + } + + // === Submit if all validations passed === + try { + final tagObjects = enteredTags.map((tagName) { + final tagId = tagsMap[tagName]; + return tagId != null + ? {"id": tagId, "name": tagName} + : {"name": tagName}; + }).toList(); + + final body = { + if (id != null) "id": id, + "name": name.trim(), + "organization": organization.trim(), + "contactCategoryId": categoryId, + "projectIds": projectIds, + "bucketIds": [bucketId], + "tags": tagObjects, + "contactEmails": emails, + "contactPhones": phones, + "address": address.trim(), + "description": description.trim(), + }; + + logSafe("${id != null ? 'Updating' : 'Creating'} contact"); + + final response = id != null + ? await ApiService.updateContact(id, body) + : await ApiService.createContact(body); + + if (response == true) { + Get.back(result: true); + showAppSnackbar( + title: "Success", + message: id != null + ? "Contact updated successfully" + : "Contact created successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to ${id != null ? 'update' : 'create'} contact", + type: SnackbarType.error, + ); + } + } catch (e) { + logSafe("Submit contact error: $e", level: LogLevel.error); + showAppSnackbar( + title: "Error", + message: "Something went wrong", + type: SnackbarType.error, + ); + } + } + + void filterOrganizationSuggestions(String query) { + if (query.trim().isEmpty) { + filteredOrgSuggestions.clear(); + return; + } + + final lower = query.toLowerCase(); + filteredOrgSuggestions.assignAll( + organizationNames + .where((name) => name.toLowerCase().contains(lower)) + .toList(), + ); + logSafe("Filtered organization suggestions for: \$query", + level: LogLevel.debug); + } + + Future fetchGlobalProjects() async { + try { + final response = await ApiService.getGlobalProjects(); + if (response != null) { + final names = []; + for (var item in response) { + final name = item['name']?.toString().trim(); + final id = item['id']?.toString().trim(); + if (name != null && id != null && name.isNotEmpty) { + projectsMap[name] = id; + names.add(name); + } + } + globalProjects.assignAll(names); + logSafe("Fetched \${names.length} global projects"); + } + } catch (e) { + logSafe("Failed to fetch global projects: \$e", level: LogLevel.error); + } + } + + Future fetchTags() async { + try { + final response = await ApiService.getContactTagList(); + if (response != null && response['data'] is List) { + tags.assignAll(List.from( + response['data'].map((e) => e['name'] ?? '').where((e) => e != ''), + )); + logSafe("Fetched \${tags.length} tags"); + } + } catch (e) { + logSafe("Failed to fetch tags: \$e", level: LogLevel.error); + } + } + + void filterSuggestions(String query) { + if (query.trim().isEmpty) { + filteredSuggestions.clear(); + return; + } + + final lower = query.toLowerCase(); + filteredSuggestions.assignAll( + tags + .where((tag) => + tag.toLowerCase().contains(lower) && !enteredTags.contains(tag)) + .toList(), + ); + logSafe("Filtered tag suggestions for: \$query", level: LogLevel.debug); + } + + void clearSuggestions() { + filteredSuggestions.clear(); + logSafe("Cleared tag suggestions", level: LogLevel.debug); + } + + Future fetchCategories() async { + try { + final response = await ApiService.getContactCategoryList(); + if (response != null && response['data'] is List) { + final names = []; + for (var item in response['data']) { + final name = item['name']?.toString().trim(); + final id = item['id']?.toString().trim(); + if (name != null && id != null && name.isNotEmpty) { + categoriesMap[name] = id; + names.add(name); + } + } + categories.assignAll(names); + logSafe("Fetched \${names.length} contact categories"); + } + } catch (e) { + logSafe("Failed to fetch categories: \$e", level: LogLevel.error); + } + } + + void addEnteredTag(String tag) { + if (tag.trim().isNotEmpty && !enteredTags.contains(tag.trim())) { + enteredTags.add(tag.trim()); + logSafe("Added tag: \$tag", level: LogLevel.debug); + } + } + + void removeEnteredTag(String tag) { + enteredTags.remove(tag); + logSafe("Removed tag: \$tag", level: LogLevel.debug); + } +} diff --git a/lib/controller/directory/directory_controller.dart b/lib/controller/directory/directory_controller.dart new file mode 100644 index 0000000..9e70d51 --- /dev/null +++ b/lib/controller/directory/directory_controller.dart @@ -0,0 +1,256 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/model/directory/contact_model.dart'; +import 'package:marco/model/directory/contact_bucket_list_model.dart'; +import 'package:marco/model/directory/directory_comment_model.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; + +class DirectoryController extends GetxController { + RxList allContacts = [].obs; + RxList filteredContacts = [].obs; + RxList contactCategories = [].obs; + RxList selectedCategories = [].obs; + RxList selectedBuckets = [].obs; + RxBool isActive = true.obs; + RxBool isLoading = false.obs; + RxList contactBuckets = [].obs; + RxString searchQuery = ''.obs; + RxBool showFabMenu = false.obs; + final RxBool showFullEditorToolbar = false.obs; + final RxBool isEditorFocused = false.obs; + RxBool isNotesView = false.obs; + + final Map> contactCommentsMap = {}; + RxList getCommentsForContact(String contactId) { + return contactCommentsMap[contactId] ?? [].obs; + } + + final editingCommentId = Rxn(); + + @override + void onInit() { + super.onInit(); + fetchContacts(); + fetchBuckets(); + } +// inside DirectoryController + + Future updateComment(DirectoryComment comment) async { + try { + logSafe( + "Attempting to update comment. id: ${comment.id}, contactId: ${comment.contactId}"); + + final commentList = contactCommentsMap[comment.contactId]; + final oldComment = + commentList?.firstWhereOrNull((c) => c.id == comment.id); + + if (oldComment == null) { + logSafe("Old comment not found. id: ${comment.id}"); + } else { + logSafe("Old comment note: ${oldComment.note}"); + logSafe("New comment note: ${comment.note}"); + } + + if (oldComment != null && oldComment.note.trim() == comment.note.trim()) { + logSafe("No changes detected in comment. id: ${comment.id}"); + showAppSnackbar( + title: "No Changes", + message: "No changes were made to the comment.", + type: SnackbarType.info, + ); + return; + } + + final success = await ApiService.updateContactComment( + comment.id, + comment.note, + comment.contactId, + ); + + if (success) { + logSafe("Comment updated successfully. id: ${comment.id}"); + await fetchCommentsForContact(comment.contactId); + + // ✅ Show success message + showAppSnackbar( + title: "Success", + message: "Comment updated successfully.", + type: SnackbarType.success, + ); + } else { + logSafe("Failed to update comment via API. id: ${comment.id}"); + showAppSnackbar( + title: "Error", + message: "Failed to update comment.", + type: SnackbarType.error, + ); + } + } catch (e, stackTrace) { + logSafe("Update comment failed: ${e.toString()}"); + logSafe("StackTrace: ${stackTrace.toString()}"); + showAppSnackbar( + title: "Error", + message: "Failed to update comment.", + type: SnackbarType.error, + ); + } + } + + Future fetchCommentsForContact(String contactId) async { + try { + final data = await ApiService.getDirectoryComments(contactId); + logSafe("Fetched comments for contact $contactId: $data"); + + final comments = + data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? []; + + if (!contactCommentsMap.containsKey(contactId)) { + contactCommentsMap[contactId] = [].obs; + } + + contactCommentsMap[contactId]!.assignAll(comments); + contactCommentsMap[contactId]?.refresh(); + } catch (e) { + logSafe("Error fetching comments for contact $contactId: $e", + level: LogLevel.error); + + contactCommentsMap[contactId] ??= [].obs; + contactCommentsMap[contactId]!.clear(); + } + } + + Future fetchBuckets() async { + try { + final response = await ApiService.getContactBucketList(); + if (response != null && response['data'] is List) { + final buckets = (response['data'] as List) + .map((e) => ContactBucket.fromJson(e)) + .toList(); + contactBuckets.assignAll(buckets); + } else { + contactBuckets.clear(); + } + } catch (e) { + logSafe("Bucket fetch error: $e", level: LogLevel.error); + } + } + + Future fetchContacts({bool active = true}) async { + try { + isLoading.value = true; + + final response = await ApiService.getDirectoryData(isActive: active); + + if (response != null) { + final contacts = response.map((e) => ContactModel.fromJson(e)).toList(); + allContacts.assignAll(contacts); + extractCategoriesFromContacts(); + applyFilters(); + } else { + allContacts.clear(); + filteredContacts.clear(); + } + } catch (e) { + logSafe("Directory fetch error: $e", level: LogLevel.error); + } finally { + isLoading.value = false; + } + } + + void extractCategoriesFromContacts() { + final uniqueCategories = {}; + + for (final contact in allContacts) { + final category = contact.contactCategory; + if (category != null && !uniqueCategories.containsKey(category.id)) { + uniqueCategories[category.id] = category; + } + } + + contactCategories.value = uniqueCategories.values.toList(); + } + + void applyFilters() { + final query = searchQuery.value.toLowerCase(); + + filteredContacts.value = allContacts.where((contact) { + final categoryMatch = selectedCategories.isEmpty || + (contact.contactCategory != null && + selectedCategories.contains(contact.contactCategory!.id)); + + final bucketMatch = selectedBuckets.isEmpty || + contact.bucketIds.any((id) => selectedBuckets.contains(id)); + + // Name, org, email, phone, tags + final nameMatch = contact.name.toLowerCase().contains(query); + final orgMatch = contact.organization.toLowerCase().contains(query); + + final emailMatch = contact.contactEmails + .any((e) => e.emailAddress.toLowerCase().contains(query)); + + final phoneMatch = contact.contactPhones + .any((p) => p.phoneNumber.toLowerCase().contains(query)); + + final tagMatch = + contact.tags.any((tag) => tag.name.toLowerCase().contains(query)); + + final categoryNameMatch = + contact.contactCategory?.name.toLowerCase().contains(query) ?? false; + + final bucketNameMatch = contact.bucketIds.any((id) { + final bucketName = contactBuckets + .firstWhereOrNull((b) => b.id == id) + ?.name + .toLowerCase() ?? + ''; + return bucketName.contains(query); + }); + + final searchMatch = query.isEmpty || + nameMatch || + orgMatch || + emailMatch || + phoneMatch || + tagMatch || + categoryNameMatch || + bucketNameMatch; + + return categoryMatch && bucketMatch && searchMatch; + }).toList(); + } + + void toggleCategory(String categoryId) { + if (selectedCategories.contains(categoryId)) { + selectedCategories.remove(categoryId); + } else { + selectedCategories.add(categoryId); + } + } + + void toggleBucket(String bucketId) { + if (selectedBuckets.contains(bucketId)) { + selectedBuckets.remove(bucketId); + } else { + selectedBuckets.add(bucketId); + } + } + + void updateSearchQuery(String value) { + searchQuery.value = value; + applyFilters(); + } + + String getBucketNames(ContactModel contact, List allBuckets) { + return contact.bucketIds + .map((id) => allBuckets.firstWhereOrNull((b) => b.id == id)?.name ?? '') + .where((name) => name.isNotEmpty) + .join(', '); + } + + bool hasActiveFilters() { + return selectedCategories.isNotEmpty || + selectedBuckets.isNotEmpty || + searchQuery.value.trim().isNotEmpty; + } +} diff --git a/lib/controller/directory/notes_controller.dart b/lib/controller/directory/notes_controller.dart new file mode 100644 index 0000000..709b4e0 --- /dev/null +++ b/lib/controller/directory/notes_controller.dart @@ -0,0 +1,126 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/directory/note_list_response_model.dart'; + +class NotesController extends GetxController { + RxList notesList = [].obs; + RxBool isLoading = false.obs; + RxnString editingNoteId = RxnString(); + RxString searchQuery = ''.obs; + + List get filteredNotesList { + if (searchQuery.isEmpty) return notesList; + + final query = searchQuery.value.toLowerCase(); + return notesList.where((note) { + return note.note.toLowerCase().contains(query) || + note.contactName.toLowerCase().contains(query) || + note.organizationName.toLowerCase().contains(query) || + note.createdBy.firstName.toLowerCase().contains(query); + }).toList(); + } + + @override + void onInit() { + super.onInit(); + fetchNotes(); + } + + Future fetchNotes({int pageSize = 1000, int pageNumber = 1}) async { + isLoading.value = true; + logSafe( + "📤 Fetching directory notes with pageSize=$pageSize & pageNumber=$pageNumber"); + + try { + final response = await ApiService.getDirectoryNotes( + pageSize: pageSize, pageNumber: pageNumber); + logSafe("💡 Directory Notes Response: $response"); + + if (response == null) { + logSafe("⚠️ Response is null while fetching directory notes"); + notesList.clear(); + } else { + logSafe("💡 Directory Notes Response: $response"); + notesList.value = NotePaginationData.fromJson(response).data; + } + } catch (e, st) { + logSafe("💥 Error occurred while fetching directory notes", + error: e, stackTrace: st); + notesList.clear(); + } finally { + isLoading.value = false; + } + } + + Future updateNote(NoteModel updatedNote) async { + try { + logSafe( + "Attempting to update note. id: ${updatedNote.id}, contactId: ${updatedNote.contactId}"); + + final oldNote = notesList.firstWhereOrNull((n) => n.id == updatedNote.id); + + if (oldNote != null && oldNote.note.trim() == updatedNote.note.trim()) { + logSafe("No changes detected in note. id: ${updatedNote.id}"); + showAppSnackbar( + title: "No Changes", + message: "No changes were made to the note.", + type: SnackbarType.info, + ); + return; + } + + final success = await ApiService.updateContactComment( + updatedNote.id, + updatedNote.note, + updatedNote.contactId, + ); + + if (success) { + logSafe("Note updated successfully. id: ${updatedNote.id}"); + final index = notesList.indexWhere((n) => n.id == updatedNote.id); + if (index != -1) { + notesList[index] = updatedNote; + notesList.refresh(); + } + showAppSnackbar( + title: "Success", + message: "Note updated successfully.", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to update note.", + type: SnackbarType.error, + ); + } + } catch (e, stackTrace) { + logSafe("Update note failed: ${e.toString()}"); + logSafe("StackTrace: ${stackTrace.toString()}"); + showAppSnackbar( + title: "Error", + message: "Failed to update note.", + type: SnackbarType.error, + ); + } + } + + void addNote(NoteModel note) { + notesList.insert(0, note); + logSafe("Note added to list"); + } + + void deleteNote(int index) { + if (index >= 0 && index < notesList.length) { + notesList.removeAt(index); + logSafe("Note removed from list at index $index"); + } + } + + void clearAllNotes() { + notesList.clear(); + logSafe("All notes cleared from list"); + } +} diff --git a/lib/controller/permission_controller.dart b/lib/controller/permission_controller.dart index 1eb55d5..0802f33 100644 --- a/lib/controller/permission_controller.dart +++ b/lib/controller/permission_controller.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/permission_service.dart'; import 'package:marco/model/user_permission.dart'; import 'package:marco/model/employee_info.dart'; @@ -17,8 +17,51 @@ class PermissionController extends GetxController { @override void onInit() { super.onInit(); - _loadDataFromAPI(); - _startAutoRefresh(); + _initialize(); + } + + Future _initialize() async { + final token = await _getAuthToken(); + if (token?.isNotEmpty ?? false) { + await loadData(token!); + _startAutoRefresh(); + } else { + logSafe("Token is null or empty. Skipping API load and auto-refresh.", level: LogLevel.warning); + } + } + + Future _getAuthToken() async { + try { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString('jwt_token'); + logSafe("Auth token retrieved: $token", level: LogLevel.debug); + return token; + } catch (e, stacktrace) { + logSafe("Error retrieving auth token", level: LogLevel.error, error: e, stackTrace: stacktrace); + return null; + } + } + + Future loadData(String token) async { + try { + final userData = await PermissionService.fetchAllUserData(token); + _updateState(userData); + await _storeData(); + logSafe("Data loaded and state updated successfully."); + } catch (e, stacktrace) { + logSafe("Error loading data from API", level: LogLevel.error, error: e, stackTrace: stacktrace); + } + } + + void _updateState(Map userData) { + try { + permissions.assignAll(userData['permissions']); + employeeInfo.value = userData['employeeInfo']; + projectsInfo.assignAll(userData['projects']); + logSafe("State updated with user data."); + } catch (e, stacktrace) { + logSafe("Error updating state", level: LogLevel.error, error: e, stackTrace: stacktrace); + } } Future _storeData() async { @@ -50,54 +93,15 @@ class PermissionController extends GetxController { } } - Future _loadDataFromAPI() async { - final token = await _getAuthToken(); - if (token?.isNotEmpty ?? false) { - await loadData(token!); - } else { - logSafe("No token found for loading API data.", level: LogLevel.warning); - } - } - - Future loadData(String token) async { - try { - final userData = await PermissionService.fetchAllUserData(token); - _updateState(userData); - await _storeData(); - logSafe("Data loaded and state updated successfully."); - } catch (e, stacktrace) { - logSafe("Error loading data from API", level: LogLevel.error, error: e, stackTrace: stacktrace); - } - } - - void _updateState(Map userData) { - try { - permissions.assignAll(userData['permissions']); - employeeInfo.value = userData['employeeInfo']; - projectsInfo.assignAll(userData['projects']); - - logSafe("State updated with new user data.", sensitive: true); - } catch (e, stacktrace) { - logSafe("Error updating state", level: LogLevel.error, error: e, stackTrace: stacktrace); - } - } - - Future _getAuthToken() async { - try { - final prefs = await SharedPreferences.getInstance(); - final token = prefs.getString('jwt_token'); - logSafe("Auth token retrieved successfully.", sensitive: true); - return token; - } catch (e, stacktrace) { - logSafe("Error retrieving auth token", level: LogLevel.error, error: e, stackTrace: stacktrace); - return null; - } - } - void _startAutoRefresh() { _refreshTimer = Timer.periodic(Duration(minutes: 30), (timer) async { logSafe("Auto-refresh triggered."); - await _loadDataFromAPI(); + final token = await _getAuthToken(); + if (token?.isNotEmpty ?? false) { + await loadData(token!); + } else { + logSafe("Token missing during auto-refresh. Skipping.", level: LogLevel.warning); + } }); } @@ -116,7 +120,7 @@ class PermissionController extends GetxController { @override void onClose() { _refreshTimer?.cancel(); - logSafe("PermissionController disposed and timer cancelled."); + logSafe("PermissionController disposed and auto-refresh timer cancelled."); super.onClose(); } } diff --git a/lib/controller/project_controller.dart b/lib/controller/project_controller.dart index 6fed738..a31cfcd 100644 --- a/lib/controller/project_controller.dart +++ b/lib/controller/project_controller.dart @@ -66,7 +66,7 @@ class ProjectController extends GetxController { isProjectSelectionExpanded.value = false; logSafe("Projects fetched: ${projects.length}"); } else { - logSafe("No projects found or API call failed.", level: LogLevel.warning); + logSafe("No Global projects found or API call failed.", level: LogLevel.warning); } isLoadingProjects.value = false; diff --git a/lib/controller/task_planing/add_task_controller.dart b/lib/controller/task_planing/add_task_controller.dart index 4e644ae..8940fcf 100644 --- a/lib/controller/task_planing/add_task_controller.dart +++ b/lib/controller/task_planing/add_task_controller.dart @@ -147,6 +147,6 @@ class AddTaskController extends GetxController { void selectCategory(String id) { selectedCategoryId.value = id; selectedCategoryName.value = categoryIdNameMap[id]; - logSafe("Category selected", level: LogLevel.debug, sensitive: true); + logSafe("Category selected", level: LogLevel.debug, ); } } diff --git a/lib/controller/task_planing/daily_task_planing_controller.dart b/lib/controller/task_planing/daily_task_planing_controller.dart index 32b7e59..2e0a05c 100644 --- a/lib/controller/task_planing/daily_task_planing_controller.dart +++ b/lib/controller/task_planing/daily_task_planing_controller.dart @@ -50,12 +50,12 @@ class DailyTaskPlaningController extends GetxController { .where((e) => uploadingStates[e.id]?.value == true) .toList(); selectedEmployees.value = selected; - logSafe("Updated selected employees", level: LogLevel.debug, sensitive: true); + logSafe("Updated selected employees", level: LogLevel.debug, ); } void onRoleSelected(String? roleId) { selectedRoleId.value = roleId; - logSafe("Role selected", level: LogLevel.info, sensitive: true); + logSafe("Role selected", level: LogLevel.info, ); } Future fetchRoles() async { @@ -137,7 +137,7 @@ class DailyTaskPlaningController extends GetxController { final data = response?['data']; if (data != null) { dailyTasks = [TaskPlanningDetailsModel.fromJson(data)]; - logSafe("Daily task Planning Details fetched", level: LogLevel.info, sensitive: true); + logSafe("Daily task Planning Details fetched", level: LogLevel.info, ); } else { logSafe("Data field is null", level: LogLevel.warning); } @@ -164,14 +164,14 @@ class DailyTaskPlaningController extends GetxController { uploadingStates[emp.id] = false.obs; } logSafe("Employees fetched: ${employees.length} for project $projectId", - level: LogLevel.info, sensitive: true); + level: LogLevel.info, ); } else { employees = []; - logSafe("No employees found for project $projectId", level: LogLevel.warning, sensitive: true); + logSafe("No employees found for project $projectId", level: LogLevel.warning, ); } } catch (e, stack) { logSafe("Error fetching employees for project $projectId", - level: LogLevel.error, error: e, stackTrace: stack, sensitive: true); + level: LogLevel.error, error: e, stackTrace: stack, ); } finally { isLoading.value = false; update(); diff --git a/lib/controller/task_planing/report_task_action_controller.dart b/lib/controller/task_planing/report_task_action_controller.dart index 96e782d..8329898 100644 --- a/lib/controller/task_planing/report_task_action_controller.dart +++ b/lib/controller/task_planing/report_task_action_controller.dart @@ -272,18 +272,18 @@ class ReportTaskActionController extends MyController { final pickedFile = await _picker.pickImage(source: ImageSource.camera, imageQuality: 75); if (pickedFile != null) { selectedImages.add(File(pickedFile.path)); - logSafe("Image added from camera: ${pickedFile.path}", sensitive: true); + logSafe("Image added from camera: ${pickedFile.path}", ); } } else { final pickedFiles = await _picker.pickMultiImage(imageQuality: 75); selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path))); - logSafe("${pickedFiles.length} images added from gallery.", sensitive: true); + logSafe("${pickedFiles.length} images added from gallery.", ); } } void removeImageAt(int index) { if (index >= 0 && index < selectedImages.length) { - logSafe("Removing image at index $index", sensitive: true); + logSafe("Removing image at index $index", ); selectedImages.removeAt(index); } } diff --git a/lib/controller/task_planing/report_task_controller.dart b/lib/controller/task_planing/report_task_controller.dart index 35077c7..02e4602 100644 --- a/lib/controller/task_planing/report_task_controller.dart +++ b/lib/controller/task_planing/report_task_controller.dart @@ -83,7 +83,7 @@ class ReportTaskController extends MyController { required DateTime reportedDate, List? images, }) async { - logSafe("Reporting task for projectId", sensitive: true); + logSafe("Reporting task for projectId", ); final completedWork = completedWorkController.text.trim(); if (completedWork.isEmpty || int.tryParse(completedWork) == null || int.parse(completedWork) < 0) { _showError("Completed work must be a positive number."); @@ -138,7 +138,7 @@ class ReportTaskController extends MyController { required String comment, List? images, }) async { - logSafe("Submitting comment for project", sensitive: true); + logSafe("Submitting comment for project", ); final commentField = commentController.text.trim(); if (commentField.isEmpty) { @@ -221,7 +221,7 @@ class ReportTaskController extends MyController { final pickedFiles = await _picker.pickMultiImage(imageQuality: 75); selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path))); } - logSafe("Images picked: ${selectedImages.length}", sensitive: true); + logSafe("Images picked: ${selectedImages.length}", ); } catch (e) { logSafe("Error picking images", level: LogLevel.warning, error: e); } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 12809fd..4dbeec6 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -25,10 +25,22 @@ class ApiEndpoints { static const String getDailyTask = "/task/list"; static const String reportTask = "/task/report"; static const String commentTask = "/task/comment"; - static const String dailyTaskDetails = "/project/details"; + static const String dailyTaskDetails = "/project/details-old"; static const String assignDailyTask = "/task/assign"; static const String getWorkStatus = "/master/work-status"; static const String approveReportAction = "/task/approve"; static const String assignTask = "/project/task"; static const String getmasterWorkCategories = "/Master/work-categories"; + + ////// Directory Screen API Endpoints + static const String getDirectoryContacts = "/directory"; + static const String getDirectoryBucketList = "/directory/buckets"; + static const String getDirectoryContactDetail = "/directory/notes"; + static const String getDirectoryContactCategory = "/master/contact-categories"; + static const String getDirectoryContactTags = "/master/contact-tags"; + static const String getDirectoryOrganization = "/directory/organization"; + static const String createContact = "/directory"; + static const String updateContact = "/directory"; + static const String getDirectoryNotes = "/directory/notes"; + static const String updateDirectoryNotes = "/directory/note"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 805446b..75a3abf 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -106,23 +106,45 @@ class ApiService { bool hasRetried = false, }) async { String? token = await _getToken(); - if (token == null) return null; + if (token == null) { + logSafe("Token is null. Cannot proceed with GET request.", + level: LogLevel.error); + return null; + } final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint") .replace(queryParameters: queryParams); - logSafe("GET $uri"); + + logSafe("Initiating GET request", level: LogLevel.debug); + logSafe("URL: $uri", level: LogLevel.debug); + logSafe("Query Parameters: ${queryParams ?? {}}", level: LogLevel.debug); + logSafe("Headers: ${_headers(token)}", level: LogLevel.debug); try { final response = await http.get(uri, headers: _headers(token)).timeout(timeout); + + logSafe("Response Status: ${response.statusCode}", level: LogLevel.debug); + logSafe("Response Body: ${response.body}", level: LogLevel.debug); + if (response.statusCode == 401 && !hasRetried) { - logSafe("Unauthorized. Attempting token refresh..."); + logSafe("Unauthorized (401). Attempting token refresh...", + level: LogLevel.warning); + if (await AuthService.refreshToken()) { - return await _getRequest(endpoint, - queryParams: queryParams, hasRetried: true); + logSafe("Token refresh succeeded. Retrying request...", + level: LogLevel.info); + return await _getRequest( + endpoint, + queryParams: queryParams, + hasRetried: true, + ); } - logSafe("Token refresh failed."); + + logSafe("Token refresh failed. Aborting request.", + level: LogLevel.error); } + return response; } catch (e) { logSafe("HTTP GET Exception: $e", level: LogLevel.error); @@ -141,7 +163,7 @@ class ApiService { final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint"); logSafe("POST $uri\nHeaders: ${_headers(token)}\nBody: $body", - sensitive: true); + ); try { final response = await http @@ -162,6 +184,49 @@ class ApiService { } } + static Future _putRequest( + String endpoint, + dynamic body, { + Map? additionalHeaders, + Duration customTimeout = timeout, + bool hasRetried = false, + }) async { + String? token = await _getToken(); + if (token == null) return null; + + final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint"); + logSafe( + "PUT $uri\nHeaders: ${_headers(token)}\nBody: $body", + ); + final headers = { + ..._headers(token), + if (additionalHeaders != null) ...additionalHeaders, + }; + + logSafe("PUT $uri\nHeaders: $headers\nBody: $body", ); + + try { + final response = await http + .put(uri, headers: headers, body: jsonEncode(body)) + .timeout(customTimeout); + + if (response.statusCode == 401 && !hasRetried) { + logSafe("Unauthorized PUT. Attempting token refresh..."); + if (await AuthService.refreshToken()) { + return await _putRequest(endpoint, body, + additionalHeaders: additionalHeaders, + customTimeout: customTimeout, + hasRetried: true); + } + } + + return response; + } catch (e) { + logSafe("HTTP PUT Exception: $e", level: LogLevel.error); + return null; + } + } + // === Dashboard Endpoints === static Future?> getDashboardAttendanceOverview( @@ -177,6 +242,227 @@ class ApiService { : null); } + /// Directory calling the API + static Future?> getDirectoryNotes({ + int pageSize = 1000, + int pageNumber = 1, + }) async { + final queryParams = { + 'pageSize': pageSize.toString(), + 'pageNumber': pageNumber.toString(), + }; + + final response = await _getRequest( + ApiEndpoints.getDirectoryNotes, + queryParams: queryParams, + ); + + final data = response != null + ? _parseResponse(response, label: 'Directory Notes') + : null; + + return data is Map ? data : null; + } + + static Future addContactComment(String note, String contactId) async { + final payload = { + "note": note, + "contactId": contactId, + }; + + final endpoint = ApiEndpoints.updateDirectoryNotes; + + logSafe("Adding new comment with payload: $payload"); + logSafe("Sending add comment request to $endpoint"); + + try { + final response = await _postRequest(endpoint, payload); + + if (response == null) { + logSafe("Add comment failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Add comment response status: ${response.statusCode}"); + logSafe("Add comment response body: ${response.body}"); + + final json = jsonDecode(response.body); + + if (json['success'] == true) { + logSafe("Comment added successfully for contactId: $contactId"); + return true; + } else { + logSafe("Failed to add comment: ${json['message']}", + level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception during addComment API: ${e.toString()}", + level: LogLevel.error); + logSafe("StackTrace: ${stack.toString()}", level: LogLevel.debug); + } + + return false; + } + + static Future updateContactComment( + String commentId, String note, String contactId) async { + final payload = { + "id": commentId, + "contactId": contactId, + "note": note, + }; + + final endpoint = "${ApiEndpoints.updateDirectoryNotes}/$commentId"; + + final headers = { + "comment-id": commentId, + }; + + logSafe("Updating comment with payload: $payload"); + logSafe("Headers for update comment: $headers"); + logSafe("Sending update comment request to $endpoint"); + + try { + final response = await _putRequest( + endpoint, + payload, + additionalHeaders: headers, + ); + + if (response == null) { + logSafe("Update comment failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Update comment response status: ${response.statusCode}"); + logSafe("Update comment response body: ${response.body}"); + + final json = jsonDecode(response.body); + + if (json['success'] == true) { + logSafe("Comment updated successfully. commentId: $commentId"); + return true; + } else { + logSafe("Failed to update comment: ${json['message']}", + level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception during updateComment API: ${e.toString()}", + level: LogLevel.error); + logSafe("StackTrace: ${stack.toString()}", level: LogLevel.debug); + } + + return false; + } + + static Future?> getDirectoryComments(String contactId) async { + final url = "${ApiEndpoints.getDirectoryNotes}/$contactId"; + final response = await _getRequest(url); + final data = response != null + ? _parseResponse(response, label: 'Directory Comments') + : null; + + return data is List ? data : null; + } + + static Future updateContact( + String contactId, Map payload) async { + try { + final endpoint = "${ApiEndpoints.updateContact}/$contactId"; + + logSafe("Updating contact [$contactId] with payload: $payload"); + + final response = await _putRequest(endpoint, payload); + if (response != null) { + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Contact updated successfully."); + return true; + } else { + logSafe("Update contact failed: ${json['message']}", + level: LogLevel.warning); + } + } + } catch (e) { + logSafe("Error updating contact: $e", level: LogLevel.error); + } + return false; + } + + static Future createContact(Map payload) async { + try { + logSafe("Submitting contact payload: $payload"); + + final response = await _postRequest(ApiEndpoints.createContact, payload); + if (response != null) { + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Contact created successfully."); + return true; + } else { + logSafe("Create contact failed: ${json['message']}", + level: LogLevel.warning); + } + } + } catch (e) { + logSafe("Error creating contact: $e", level: LogLevel.error); + } + return false; + } + + static Future> getOrganizationList() async { + try { + final url = ApiEndpoints.getDirectoryOrganization; + logSafe("Sending GET request to: $url", level: LogLevel.info); + + final response = await _getRequest(url); + + logSafe("Response status: ${response?.statusCode}", + level: LogLevel.debug); + logSafe("Response body: ${response?.body}", level: LogLevel.debug); + + if (response != null && response.statusCode == 200) { + final body = jsonDecode(response.body); + if (body['success'] == true && body['data'] is List) { + return List.from(body['data']); + } + } + } catch (e, stackTrace) { + logSafe("Failed to fetch organization names: $e", level: LogLevel.error); + logSafe("Stack trace: $stackTrace", level: LogLevel.debug); + } + return []; + } + + static Future?> getContactCategoryList() async => + _getRequest(ApiEndpoints.getDirectoryContactCategory).then((res) => + res != null + ? _parseResponseForAllData(res, label: 'Contact Category List') + : null); + + static Future?> getContactTagList() async => + _getRequest(ApiEndpoints.getDirectoryContactTags).then((res) => + res != null + ? _parseResponseForAllData(res, label: 'Contact Tag List') + : null); + + static Future?> getDirectoryData( + {required bool isActive}) async { + final queryParams = { + "active": isActive.toString(), + }; + + return _getRequest(ApiEndpoints.getDirectoryContacts, + queryParams: queryParams) + .then((res) => + res != null ? _parseResponse(res, label: 'Directory Data') : null); + } + + static Future?> getContactBucketList() async => + _getRequest(ApiEndpoints.getDirectoryBucketList).then((res) => res != null + ? _parseResponseForAllData(res, label: 'Contact Bucket List') + : null); + // === Attendance APIs === static Future?> getProjects() async => @@ -319,7 +605,7 @@ class ApiService { "jobRoleId": jobRoleId, }; final response = await _postRequest( - ApiEndpoints.reportTask, + ApiEndpoints.createEmployee, body, customTimeout: extendedTimeout, ); diff --git a/lib/helpers/services/app_initializer.dart b/lib/helpers/services/app_initializer.dart index 258e3be..5d55b0c 100644 --- a/lib/helpers/services/app_initializer.dart +++ b/lib/helpers/services/app_initializer.dart @@ -7,42 +7,70 @@ import 'package:marco/helpers/theme/theme_customizer.dart'; import 'package:marco/helpers/theme/app_theme.dart'; import 'package:url_strategy/url_strategy.dart'; import 'package:marco/helpers/services/app_logger.dart'; - +import 'package:marco/helpers/services/auth_service.dart'; Future initializeApp() async { try { - logSafe("Starting app initialization..."); + logSafe("💡 Starting app initialization..."); setPathUrlStrategy(); - logSafe("URL strategy set."); + logSafe("💡 URL strategy set."); SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( statusBarColor: Color.fromARGB(255, 255, 0, 0), statusBarIconBrightness: Brightness.light, )); - logSafe("System UI overlay style set."); + logSafe("💡 System UI overlay style set."); await LocalStorage.init(); - logSafe("Local storage initialized."); + logSafe("💡 Local storage initialized."); + + // If a refresh token is found, try to refresh the JWT token + final refreshToken = await LocalStorage.getRefreshToken(); + if (refreshToken != null && refreshToken.isNotEmpty) { + logSafe("🔁 Refresh token found. Attempting to refresh JWT..."); + final success = await AuthService.refreshToken(); + + if (!success) { + logSafe("⚠️ Refresh token invalid or expired. Skipping controller injection."); + // Optionally, clear tokens and force logout here if needed + } + } else { + logSafe("❌ No refresh token found. Skipping refresh."); + } await ThemeCustomizer.init(); - logSafe("Theme customizer initialized."); + logSafe("💡 Theme customizer initialized."); - Get.put(PermissionController()); - logSafe("PermissionController injected."); + final token = LocalStorage.getString('jwt_token'); + if (token != null && token.isNotEmpty) { + if (!Get.isRegistered()) { + Get.put(PermissionController()); + logSafe("💡 PermissionController injected."); + } - Get.put(ProjectController(), permanent: true); - logSafe("ProjectController injected as permanent."); + if (!Get.isRegistered()) { + Get.put(ProjectController(), permanent: true); + logSafe("💡 ProjectController injected as permanent."); + } + + // Load data into controllers if required + await Get.find().loadData(token); + await Get.find().fetchProjects(); + } else { + logSafe("⚠️ No valid JWT token found. Skipping controller initialization."); + } AppStyle.init(); - logSafe("AppStyle initialized."); + logSafe("💡 AppStyle initialized."); - logSafe("App initialization completed successfully."); + logSafe("✅ App initialization completed successfully."); } catch (e, stacktrace) { - logSafe("Error during app initialization", + logSafe( + "⛔ Error during app initialization", level: LogLevel.error, error: e, stackTrace: stacktrace, ); - rethrow; + rethrow; } } diff --git a/lib/helpers/services/auth_service.dart b/lib/helpers/services/auth_service.dart index bff8272..5c0bc0c 100644 --- a/lib/helpers/services/auth_service.dart +++ b/lib/helpers/services/auth_service.dart @@ -247,32 +247,45 @@ class AuthService { } /// Handle login success flow - static Future _handleLoginSuccess(Map data) async { - logSafe("Processing login success..."); + static Future _handleLoginSuccess(Map data) async { + logSafe("Processing login success..."); - final jwtToken = data['token']; - final refreshToken = data['refreshToken']; - final mpinToken = data['mpinToken']; + final jwtToken = data['token']; + final refreshToken = data['refreshToken']; + final mpinToken = data['mpinToken']; - await LocalStorage.setJwtToken(jwtToken); - await LocalStorage.setLoggedInUser(true); + // Save tokens + await LocalStorage.setJwtToken(jwtToken); + await LocalStorage.setLoggedInUser(true); - if (refreshToken != null) await LocalStorage.setRefreshToken(refreshToken); - - if (mpinToken != null && mpinToken.isNotEmpty) { - await LocalStorage.setMpinToken(mpinToken); - await LocalStorage.setIsMpin(true); - } else { - await LocalStorage.setIsMpin(false); - await LocalStorage.removeMpinToken(); - } - - final permissionController = Get.put(PermissionController()); - await permissionController.loadData(jwtToken); - - await Get.find().fetchProjects(); - - isLoggedIn = true; - logSafe("Login flow completed."); + if (refreshToken != null) { + await LocalStorage.setRefreshToken(refreshToken); } + + if (mpinToken != null && mpinToken.isNotEmpty) { + await LocalStorage.setMpinToken(mpinToken); + await LocalStorage.setIsMpin(true); + } else { + await LocalStorage.setIsMpin(false); + await LocalStorage.removeMpinToken(); + } + + // Inject controllers if not already registered + if (!Get.isRegistered()) { + Get.put(PermissionController()); + logSafe("✅ PermissionController injected after login."); + } + + if (!Get.isRegistered()) { + Get.put(ProjectController(), permanent: true); + logSafe("✅ ProjectController injected after login."); + } + + // Load data into controllers + await Get.find().loadData(jwtToken); + await Get.find().fetchProjects(); + + isLoggedIn = true; + logSafe("✅ Login flow completed and controllers initialized."); } +} \ No newline at end of file diff --git a/lib/helpers/services/permission_service.dart b/lib/helpers/services/permission_service.dart index aa9d492..ebde963 100644 --- a/lib/helpers/services/permission_service.dart +++ b/lib/helpers/services/permission_service.dart @@ -19,10 +19,10 @@ class PermissionService { String token, { bool hasRetried = false, }) async { - logSafe("Fetching user data...", sensitive: true); + logSafe("Fetching user data...", ); if (_userDataCache.containsKey(token)) { - logSafe("User data cache hit.", sensitive: true); + logSafe("User data cache hit.", ); return _userDataCache[token]!; } diff --git a/lib/helpers/utils/date_time_utils.dart b/lib/helpers/utils/date_time_utils.dart new file mode 100644 index 0000000..2c6a732 --- /dev/null +++ b/lib/helpers/utils/date_time_utils.dart @@ -0,0 +1,38 @@ +import 'package:intl/intl.dart'; +import 'package:marco/helpers/services/app_logger.dart'; + +class DateTimeUtils { + static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) { + try { + logSafe('convertUtcToLocal: input="$utcTimeString", format="$format"'); + + final parsed = DateTime.parse(utcTimeString); + final utcDateTime = DateTime.utc( + parsed.year, + parsed.month, + parsed.day, + parsed.hour, + parsed.minute, + parsed.second, + parsed.millisecond, + parsed.microsecond, + ); + logSafe('Parsed (assumed UTC): $utcDateTime'); + + final localDateTime = utcDateTime.toLocal(); + logSafe('Converted to Local: $localDateTime'); + + final formatted = _formatDateTime(localDateTime, format: format); + logSafe('Formatted Local Time: $formatted'); + + return formatted; + } catch (e, stackTrace) { + logSafe('DateTime conversion failed: $e', error: e, stackTrace: stackTrace); + return 'Invalid Date'; + } + } + + static String _formatDateTime(DateTime dateTime, {String format = 'dd-MM-yyyy'}) { + return DateFormat(format).format(dateTime); + } +} diff --git a/lib/helpers/utils/launcher_utils.dart b/lib/helpers/utils/launcher_utils.dart new file mode 100644 index 0000000..5733bd6 --- /dev/null +++ b/lib/helpers/utils/launcher_utils.dart @@ -0,0 +1,111 @@ +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/services/app_logger.dart'; + +class LauncherUtils { + /// Launches the phone dialer with the provided phone number + static Future launchPhone(String phoneNumber) async { + logSafe('Attempting to launch phone: $phoneNumber', ); + + final Uri url = Uri(scheme: 'tel', path: phoneNumber); + await _tryLaunch(url, 'Could not launch phone'); + } + + /// Launches the email app with the provided email address + static Future launchEmail(String email) async { + logSafe('Attempting to launch email: $email', ); + + final Uri url = Uri(scheme: 'mailto', path: email); + await _tryLaunch(url, 'Could not launch email'); + } + + /// Launches WhatsApp with the provided phone number + static Future launchWhatsApp(String phoneNumber) async { + logSafe('Attempting to launch WhatsApp with: $phoneNumber', ); + + String normalized = phoneNumber.replaceAll(RegExp(r'\D'), ''); + if (!normalized.startsWith('91')) { + normalized = '91$normalized'; + } + + logSafe('Normalized WhatsApp number: $normalized', ); + + if (normalized.length < 12) { + logSafe('Invalid WhatsApp number: $normalized', ); + showAppSnackbar( + title: 'Error', + message: 'Invalid phone number for WhatsApp', + type: SnackbarType.error, + ); + return; + } + + final Uri url = Uri.parse('https://wa.me/$normalized'); + await _tryLaunch(url, 'Could not open WhatsApp'); + } + + /// Copies text to clipboard with feedback + static Future copyToClipboard(String text, {required String typeLabel}) async { + try { + logSafe('Copying "$typeLabel" to clipboard'); + + HapticFeedback.lightImpact(); + await Clipboard.setData(ClipboardData(text: text)); + showAppSnackbar( + title: 'Copied', + message: '$typeLabel copied to clipboard', + type: SnackbarType.success, + ); + } catch (e, st) { + logSafe( + 'Failed to copy $typeLabel to clipboard: $e', + stackTrace: st, + level: LogLevel.error, + + ); + showAppSnackbar( + title: 'Error', + message: 'Failed to copy $typeLabel', + type: SnackbarType.error, + ); + } + } + + /// Internal function to launch a URL and show error if failed + static Future _tryLaunch(Uri url, String errorMsg) async { + try { + logSafe('Trying to launch URL: ${url.toString()}'); + + final bool launched = await launchUrl( + url, + mode: LaunchMode.externalApplication, + ); + + if (launched) { + logSafe('URL launched successfully: ${url.toString()}'); + } else { + logSafe( + 'launchUrl returned false: ${url.toString()}', + level: LogLevel.warning, + ); + showAppSnackbar( + title: 'Error', + message: errorMsg, + type: SnackbarType.error, + ); + } + } catch (e, st) { + logSafe( + 'Exception during launch of ${url.toString()}: $e', + stackTrace: st, + level: LogLevel.error, + ); + showAppSnackbar( + title: 'Error', + message: '$errorMsg: $e', + type: SnackbarType.error, + ); + } + } +} diff --git a/lib/helpers/widgets/Directory/comment_editor_card.dart b/lib/helpers/widgets/Directory/comment_editor_card.dart new file mode 100644 index 0000000..64a712d --- /dev/null +++ b/lib/helpers/widgets/Directory/comment_editor_card.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' as quill; + +class CommentEditorCard extends StatelessWidget { + final quill.QuillController controller; + final VoidCallback onCancel; + final Future Function(quill.QuillController controller) onSave; + + const CommentEditorCard({ + super.key, + required this.controller, + required this.onCancel, + required this.onSave, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + quill.QuillSimpleToolbar( + controller: controller, + configurations: const quill.QuillSimpleToolbarConfigurations( + showBoldButton: true, + showItalicButton: true, + showUnderLineButton: true, + showListBullets: false, + showListNumbers: false, + showAlignmentButtons: true, + showLink: true, + showFontSize: false, + showFontFamily: false, + showColorButton: false, + showBackgroundColorButton: false, + showUndo: false, + showRedo: false, + showCodeBlock: false, + showQuote: false, + showSuperscript: false, + showSubscript: false, + showInlineCode: false, + showDirection: false, + showListCheck: false, + showStrikeThrough: false, + showClearFormat: false, + showDividers: false, + showHeaderStyle: false, + multiRowsDisplay: false, + ), + ), + const SizedBox(height: 38), + Container( + height: 140, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + color: const Color(0xFFFDFDFD), + ), + child: quill.QuillEditor.basic( + controller: controller, + configurations: const quill.QuillEditorConfigurations( + autoFocus: true, + expands: false, + scrollable: true, + ), + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: Wrap( + spacing: 8, + children: [ + OutlinedButton.icon( + onPressed: onCancel, + icon: const Icon(Icons.close, size: 18), + label: const Text("Cancel"), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.grey[700], + ), + ), + ElevatedButton.icon( + onPressed: () => onSave(controller), + icon: const Icon(Icons.save, size: 18), + label: const Text("Save"), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + ), + ), + ], + ), + ) + ], + ); + } +} diff --git a/lib/helpers/widgets/avatar.dart b/lib/helpers/widgets/avatar.dart index 16b546c..82f1dbf 100644 --- a/lib/helpers/widgets/avatar.dart +++ b/lib/helpers/widgets/avatar.dart @@ -6,7 +6,7 @@ class Avatar extends StatelessWidget { final String firstName; final String lastName; final double size; - final Color? backgroundColor; // Optional: allows override + final Color? backgroundColor; final Color textColor; const Avatar({ @@ -22,7 +22,7 @@ class Avatar extends StatelessWidget { Widget build(BuildContext context) { String initials = "${firstName.isNotEmpty ? firstName[0] : ''}${lastName.isNotEmpty ? lastName[0] : ''}".toUpperCase(); - final Color bgColor = backgroundColor ?? _generateColorFromName('$firstName$lastName'); + final Color bgColor = backgroundColor ?? _getFlatColorFromName('$firstName$lastName'); return MyContainer.rounded( height: size, @@ -39,12 +39,28 @@ class Avatar extends StatelessWidget { ); } - // Generate a consistent "random-like" color from the name - Color _generateColorFromName(String name) { - final hash = name.hashCode; - final r = (hash & 0xFF0000) >> 16; - final g = (hash & 0x00FF00) >> 8; - final b = (hash & 0x0000FF); - return Color.fromARGB(255, r, g, b).withOpacity(1.0); + // Use fixed flat color palette and pick based on hash + Color _getFlatColorFromName(String name) { + final colors = [ + Color(0xFFE57373), // Red + Color(0xFFF06292), // Pink + Color(0xFFBA68C8), // Purple + Color(0xFF9575CD), // Deep Purple + Color(0xFF7986CB), // Indigo + Color(0xFF64B5F6), // Blue + Color(0xFF4FC3F7), // Light Blue + Color(0xFF4DD0E1), // Cyan + Color(0xFF4DB6AC), // Teal + Color(0xFF81C784), // Green + Color(0xFFAED581), // Light Green + Color(0xFFDCE775), // Lime + Color(0xFFFFD54F), // Amber + Color(0xFFFFB74D), // Orange + Color(0xFFA1887F), // Brown + Color(0xFF90A4AE), // Blue Grey + ]; + + int index = name.hashCode.abs() % colors.length; + return colors[index]; } } diff --git a/lib/helpers/widgets/my_custom_skeleton.dart b/lib/helpers/widgets/my_custom_skeleton.dart index 1ba3a3e..e8b0090 100644 --- a/lib/helpers/widgets/my_custom_skeleton.dart +++ b/lib/helpers/widgets/my_custom_skeleton.dart @@ -184,6 +184,7 @@ static Widget buildLoadingSkeleton() { } // Daily Progress Planning (Collapsed View) + static Widget dailyProgressPlanningSkeletonCollapsedOnly() { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -225,4 +226,58 @@ static Widget buildLoadingSkeleton() { }), ); } + static Widget contactSkeletonCard() { + return MyCard.bordered( + margin: MySpacing.only(bottom: 12), + paddingAll: 16, + borderRadiusAll: 16, + shadow: MyShadow( + elevation: 1.5, + position: MyShadowPosition.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + height: 40, + width: 40, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 12, + width: 100, + color: Colors.grey.shade300, + ), + MySpacing.height(6), + Container( + height: 10, + width: 60, + color: Colors.grey.shade300, + ), + ], + ), + ), + ], + ), + MySpacing.height(16), + Container(height: 10, width: 150, color: Colors.grey.shade300), + MySpacing.height(8), + Container(height: 10, width: 100, color: Colors.grey.shade300), + MySpacing.height(8), + Container(height: 10, width: 120, color: Colors.grey.shade300), + ], + ), + ); +} + } diff --git a/lib/helpers/widgets/my_image_compressor.dart b/lib/helpers/widgets/my_image_compressor.dart index a885499..ce8bb2b 100644 --- a/lib/helpers/widgets/my_image_compressor.dart +++ b/lib/helpers/widgets/my_image_compressor.dart @@ -14,7 +14,7 @@ Future compressImageToUnder100KB(File file) async { const int maxWidth = 800; const int maxHeight = 800; - logSafe("Starting image compression...", sensitive: true); + logSafe("Starting image compression...", ); while (quality >= 10) { try { @@ -59,7 +59,7 @@ Future saveCompressedImageToFile(Uint8List bytes) async { final file = File(filePath); final savedFile = await file.writeAsBytes(bytes); - logSafe("Compressed image saved to ${savedFile.path}", sensitive: true); + logSafe("Compressed image saved to ${savedFile.path}", ); return savedFile; } catch (e, stacktrace) { logSafe("Error saving compressed image", level: LogLevel.error, error: e, stackTrace: stacktrace); diff --git a/lib/helpers/widgets/my_loading_component.dart b/lib/helpers/widgets/my_loading_component.dart index f556cef..b716abb 100644 --- a/lib/helpers/widgets/my_loading_component.dart +++ b/lib/helpers/widgets/my_loading_component.dart @@ -2,7 +2,9 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:loading_animation_widget/loading_animation_widget.dart'; import 'package:marco/images.dart'; - +import 'package:marco/helpers/widgets/my_card.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/utils/my_shadow.dart'; class LoadingComponent extends StatelessWidget { final bool isLoading; final Widget child; @@ -58,6 +60,59 @@ class LoadingComponent extends StatelessWidget { ); } } +Widget contactSkeletonCard() { + return MyCard.bordered( + margin: MySpacing.only(bottom: 12), + paddingAll: 16, + borderRadiusAll: 16, + shadow: MyShadow( + elevation: 1.5, + position: MyShadowPosition.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + height: 40, + width: 40, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 12, + width: 100, + color: Colors.grey.shade300, + ), + MySpacing.height(6), + Container( + height: 10, + width: 60, + color: Colors.grey.shade300, + ), + ], + ), + ), + ], + ), + MySpacing.height(16), + Container(height: 10, width: 150, color: Colors.grey.shade300), + MySpacing.height(8), + Container(height: 10, width: 100, color: Colors.grey.shade300), + MySpacing.height(8), + Container(height: 10, width: 120, color: Colors.grey.shade300), + ], + ), + ); +} class _LoadingAnimation extends StatelessWidget { final double imageSize; diff --git a/lib/model/attendance/attendence_action_button.dart b/lib/model/attendance/attendence_action_button.dart index 7ab8f65..08b725e 100644 --- a/lib/model/attendance/attendence_action_button.dart +++ b/lib/model/attendance/attendence_action_button.dart @@ -147,16 +147,25 @@ class _AttendanceActionButtonState extends State { pickedTime.minute, ); - if (selectedDateTime.isAfter(checkInTime)) { - return selectedDateTime; - } else { + final now = DateTime.now(); + + if (selectedDateTime.isBefore(checkInTime)) { showAppSnackbar( title: "Invalid Time", - message: "Please select a time after check-in time.", + message: "Time must be after check-in.", + type: SnackbarType.warning, + ); + return null; + } else if (selectedDateTime.isAfter(now)) { + showAppSnackbar( + title: "Invalid Time", + message: "Future time is not allowed.", type: SnackbarType.warning, ); return null; } + + return selectedDateTime; } return null; } @@ -217,6 +226,30 @@ class _AttendanceActionButtonState extends State { break; } + DateTime? selectedTime; + + // ✅ New condition: Yesterday Check-In + CheckOut action + final isYesterdayCheckIn = widget.employee.checkIn != null && + DateUtils.isSameDay( + widget.employee.checkIn, + DateTime.now().subtract(const Duration(days: 1)), + ); + + if (isYesterdayCheckIn && + widget.employee.checkOut == null && + actionText == ButtonActions.checkOut) { + selectedTime = await showTimePickerForRegularization( + context: context, + checkInTime: widget.employee.checkIn!, + ); + + if (selectedTime == null) { + widget.attendanceController.uploadingStates[uniqueLogKey]?.value = + false; + return; + } + } + final userComment = await _showCommentBottomSheet(context, actionText); if (userComment == null || userComment.isEmpty) { widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false; @@ -225,13 +258,14 @@ class _AttendanceActionButtonState extends State { bool success = false; if (actionText == ButtonActions.requestRegularize) { - final selectedTime = await showTimePickerForRegularization( - context: context, - checkInTime: widget.employee.checkIn!, - ); - if (selectedTime != null) { + final regularizeTime = selectedTime ?? + await showTimePickerForRegularization( + context: context, + checkInTime: widget.employee.checkIn!, + ); + if (regularizeTime != null) { final formattedSelectedTime = - DateFormat("hh:mm a").format(selectedTime); + DateFormat("hh:mm a").format(regularizeTime); success = await widget.attendanceController.captureAndUploadAttendance( widget.employee.id, widget.employee.employeeId, @@ -242,6 +276,18 @@ class _AttendanceActionButtonState extends State { markTime: formattedSelectedTime, ); } + } else if (selectedTime != null) { + // ✅ If selectedTime was picked in the new condition + final formattedSelectedTime = DateFormat("hh:mm a").format(selectedTime); + success = await widget.attendanceController.captureAndUploadAttendance( + widget.employee.id, + widget.employee.employeeId, + selectedProjectId, + comment: userComment, + action: updatedAction, + imageCapture: imageCapture, + markTime: formattedSelectedTime, + ); } else { success = await widget.attendanceController.captureAndUploadAttendance( widget.employee.id, diff --git a/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart b/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart index 0bcd385..a61dbac 100644 --- a/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart +++ b/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/task_planing/daily_task_planing_controller.dart'; -import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; @@ -197,16 +196,34 @@ class _AssignTaskBottomSheetState extends State { ), MySpacing.height(24), Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - MyButton( + OutlinedButton.icon( + onPressed: () => Get.back(), + icon: const Icon(Icons.close, color: Colors.red), + label: MyText.bodyMedium("Cancel", color: Colors.red), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 14), + ), + ), + ElevatedButton.icon( onPressed: _onAssignTaskPressed, - backgroundColor: const Color.fromARGB(255, 95, 132, 255), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ + icon: const Icon(Icons.check_circle_outline, + color: Colors.white), + label: MyText.bodyMedium("Assign Task", color: Colors.white), - ], + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + horizontal: 28, vertical: 14), ), ), ], diff --git a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart index 90ab576..a7f8ee1 100644 --- a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/task_planing/report_task_controller.dart'; -import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; @@ -342,9 +341,6 @@ class _CommentTaskBottomSheetState extends State } }, isLoading: controller.isLoading, - splashColor: contentTheme.secondary.withAlpha(25), - backgroundColor: Colors.blueAccent, - loadingIndicatorColor: contentTheme.onPrimary, ), MySpacing.height(10), if ((widget.taskData['taskComments'] as List?) @@ -522,52 +518,59 @@ class _CommentTaskBottomSheetState extends State ); } - Widget buildCommentActionButtons({ - required VoidCallback onCancel, - required Future Function() onSubmit, - required RxBool isLoading, - required Color splashColor, - required Color backgroundColor, - required Color loadingIndicatorColor, - double? buttonHeight, - }) { - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - MyButton.text( + Widget buildCommentActionButtons({ + required VoidCallback onCancel, + required Future Function() onSubmit, + required RxBool isLoading, + double? buttonHeight, +}) { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( onPressed: onCancel, - padding: MySpacing.xy(20, 16), - splashColor: splashColor, - child: MyText.bodySmall('Cancel'), + icon: const Icon(Icons.close, color: Colors.red, size: 18), + label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), ), - MySpacing.width(12), - Obx(() { - return MyButton( - onPressed: isLoading.value ? null : onSubmit, - elevation: 0, - padding: MySpacing.xy(20, 16), - backgroundColor: backgroundColor, - borderRadiusAll: AppStyle.buttonRadius.medium, - child: isLoading.value - ? SizedBox( - width: buttonHeight ?? 16, - height: buttonHeight ?? 16, + ), + const SizedBox(width: 16), + Expanded( + child: Obx(() { + return ElevatedButton.icon( + onPressed: isLoading.value ? null : () => onSubmit(), + icon: isLoading.value + ? const SizedBox( + width: 16, + height: 16, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - loadingIndicatorColor, - ), + valueColor: AlwaysStoppedAnimation(Colors.white), ), ) - : MyText.bodySmall( - 'Comment', - color: loadingIndicatorColor, - ), + : const Icon(Icons.check_circle_outline, color: Colors.white, size: 18), + label: isLoading.value + ? const SizedBox() + : MyText.bodyMedium("Comment", color: Colors.white, fontWeight: 600), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), ); }), - ], - ); - } + ), + ], + ); +} Widget buildRow(String label, String? value, {IconData? icon}) { return Padding( diff --git a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart index 893d6c0..c6fd28c 100644 --- a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart @@ -467,7 +467,6 @@ class _ReportActionBottomSheetState extends State reportActionId: reportActionId, approvedTaskCount: approvedTaskCount, ); - if (success) { Navigator.of(context).pop(); if (shouldShowAddTaskSheet) { @@ -488,10 +487,8 @@ class _ReportActionBottomSheetState extends State widget.taskData['plannedWork'] ?? '0') ?? 0, - activityId: - widget.activityId, - workAreaId: - widget.workAreaId, + activityId: widget.activityId, + workAreaId: widget.workAreaId, onSubmit: () { Navigator.of(context).pop(); }, @@ -502,12 +499,9 @@ class _ReportActionBottomSheetState extends State } }, isLoading: controller.isLoading, - splashColor: contentTheme.secondary.withAlpha(25), - backgroundColor: Colors.blueAccent, - loadingIndicatorColor: contentTheme.onPrimary, ), - MySpacing.height(10), + MySpacing.height(20), if ((widget.taskData['taskComments'] as List?) ?.isNotEmpty == true) ...[ @@ -683,52 +677,59 @@ class _ReportActionBottomSheetState extends State ); } - Widget buildCommentActionButtons({ - required VoidCallback onCancel, - required Future Function() onSubmit, - required RxBool isLoading, - required Color splashColor, - required Color backgroundColor, - required Color loadingIndicatorColor, - double? buttonHeight, - }) { - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - MyButton.text( + Widget buildCommentActionButtons({ + required VoidCallback onCancel, + required Future Function() onSubmit, + required RxBool isLoading, + double? buttonHeight, +}) { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( onPressed: onCancel, - padding: MySpacing.xy(20, 16), - splashColor: splashColor, - child: MyText.bodySmall('Cancel'), + icon: const Icon(Icons.close, color: Colors.red, size: 18), + label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), ), - MySpacing.width(12), - Obx(() { - return MyButton( - onPressed: isLoading.value ? null : onSubmit, - elevation: 0, - padding: MySpacing.xy(20, 16), - backgroundColor: backgroundColor, - borderRadiusAll: AppStyle.buttonRadius.medium, - child: isLoading.value - ? SizedBox( - width: buttonHeight ?? 16, - height: buttonHeight ?? 16, + ), + const SizedBox(width: 16), + Expanded( + child: Obx(() { + return ElevatedButton.icon( + onPressed: isLoading.value ? null : () => onSubmit(), + icon: isLoading.value + ? const SizedBox( + width: 16, + height: 16, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - loadingIndicatorColor, - ), + valueColor: AlwaysStoppedAnimation(Colors.white), ), ) - : MyText.bodySmall( - 'Submit Report', - color: loadingIndicatorColor, - ), + : const Icon(Icons.send, color: Colors.white, size: 18), + label: isLoading.value + ? const SizedBox() + : MyText.bodyMedium("Submit", color: Colors.white, fontWeight: 600), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), ); }), - ], - ); - } + ), + ], + ); +} Widget buildRow(String label, String? value, {IconData? icon}) { return Padding( diff --git a/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart b/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart index 9b86441..7991f4e 100644 --- a/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/task_planing/report_task_controller.dart'; -import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; @@ -346,61 +345,92 @@ class _ReportTaskBottomSheetState extends State ], ); }), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - MyButton.text( - onPressed: () => Navigator.of(context).pop(), - padding: MySpacing.xy(20, 16), - splashColor: contentTheme.secondary.withAlpha(25), - child: MyText.bodySmall('Cancel'), - ), - MySpacing.width(12), - Obx(() { - final isLoading = controller.reportStatus.value == ApiStatus.loading; - - return MyButton( - onPressed: isLoading - ? null - : () async { - if (controller.basicValidator.validateForm()) { - final success = await controller.reportTask( - projectId: controller.basicValidator.getController('task_id')?.text ?? '', - comment: controller.basicValidator.getController('comment')?.text ?? '', - completedTask: int.tryParse( - controller.basicValidator.getController('completed_work')?.text ?? '') ?? - 0, - checklist: [], - reportedDate: DateTime.now(), - images: controller.selectedImages, - ); - if (success && widget.onReportSuccess != null) { - widget.onReportSuccess!(); - } - } - }, - elevation: 0, - padding: MySpacing.xy(20, 16), - backgroundColor: Colors.blueAccent, - borderRadiusAll: AppStyle.buttonRadius.medium, - child: isLoading - ? const SizedBox( - height: 16, - width: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : MyText.bodySmall( - 'Report', - color: contentTheme.onPrimary, + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.red, size: 18), + label: MyText.bodyMedium( + "Cancel", + color: Colors.red, + fontWeight: 600, + ), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), - ); -}), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Obx(() { + final isLoading = + controller.reportStatus.value == ApiStatus.loading; + + return ElevatedButton.icon( + onPressed: isLoading + ? null + : () async { + if (controller.basicValidator.validateForm()) { + final success = await controller.reportTask( + projectId: controller.basicValidator + .getController('task_id') + ?.text ?? + '', + comment: controller.basicValidator + .getController('comment') + ?.text ?? + '', + completedTask: int.tryParse( + controller.basicValidator + .getController('completed_work') + ?.text ?? + '') ?? + 0, + checklist: [], + reportedDate: DateTime.now(), + images: controller.selectedImages, + ); + if (success && widget.onReportSuccess != null) { + widget.onReportSuccess!(); + } + } + }, + icon: isLoading + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Icon(Icons.check_circle_outline, + color: Colors.white, size: 18), + label: isLoading + ? const SizedBox.shrink() + : MyText.bodyMedium( + "Report", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), + ); + }), + ), + ], +), - ], - ), ], ), ), diff --git a/lib/model/directory/add_comment_bottom_sheet.dart b/lib/model/directory/add_comment_bottom_sheet.dart new file mode 100644 index 0000000..c208d4c --- /dev/null +++ b/lib/model/directory/add_comment_bottom_sheet.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_quill/flutter_quill.dart' as quill; +import 'package:marco/controller/directory/add_comment_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart'; + +class AddCommentBottomSheet extends StatefulWidget { + final String contactId; + + const AddCommentBottomSheet({super.key, required this.contactId}); + + @override + State createState() => _AddCommentBottomSheetState(); +} + +class _AddCommentBottomSheetState extends State { + late final AddCommentController controller; + late final quill.QuillController quillController; + + @override + void initState() { + super.initState(); + controller = Get.put(AddCommentController(contactId: widget.contactId)); + quillController = quill.QuillController.basic(); + } + + @override + void dispose() { + quillController.dispose(); + Get.delete(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: MediaQuery.of(context).viewInsets, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 12, + offset: Offset(0, -2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 40, + height: 5, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(10), + ), + ), + ), + MySpacing.height(12), + Center(child: MyText.titleMedium("Add Comment", fontWeight: 700)), + MySpacing.height(24), + CommentEditorCard( + controller: quillController, + onCancel: () => Get.back(), + onSave: (editorController) async { + final delta = editorController.document.toDelta(); + final htmlOutput = _convertDeltaToHtml(delta); + controller.updateNote(htmlOutput); + await controller.submitComment(); + }, + ), + ], + ), + ), + ), + ); + } + + String _convertDeltaToHtml(dynamic delta) { + final buffer = StringBuffer(); + bool inList = false; + + for (var op in delta.toList()) { + final data = op.data?.toString() ?? ''; + final attr = op.attributes ?? {}; + + final isListItem = attr.containsKey('list'); + final trimmedData = data.trim(); + + if (isListItem && !inList) { + buffer.write('
    '); + inList = true; + } + + if (!isListItem && inList) { + buffer.write('
'); + inList = false; + } + + if (isListItem && trimmedData.isEmpty) continue; + + if (isListItem) buffer.write('
  • '); + + if (attr.containsKey('bold')) buffer.write(''); + if (attr.containsKey('italic')) buffer.write(''); + if (attr.containsKey('underline')) buffer.write(''); + if (attr.containsKey('strike')) buffer.write(''); + if (attr.containsKey('link')) buffer.write(''); + + buffer.write(trimmedData.replaceAll('\n', '')); + + if (attr.containsKey('link')) buffer.write(''); + if (attr.containsKey('strike')) buffer.write(''); + if (attr.containsKey('underline')) buffer.write(''); + if (attr.containsKey('italic')) buffer.write(''); + if (attr.containsKey('bold')) buffer.write(''); + + if (isListItem) { + buffer.write('
  • '); + } else if (data.contains('\n')) { + buffer.write('
    '); + } + } + + if (inList) buffer.write(''); + return buffer.toString(); + } +} diff --git a/lib/model/directory/add_contact_bottom_sheet.dart b/lib/model/directory/add_contact_bottom_sheet.dart new file mode 100644 index 0000000..97d2e95 --- /dev/null +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -0,0 +1,698 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter/services.dart'; +import 'package:collection/collection.dart'; +import 'package:marco/controller/directory/add_contact_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:marco/model/directory/contact_model.dart'; + +class AddContactBottomSheet extends StatefulWidget { + final ContactModel? existingContact; + const AddContactBottomSheet({super.key, this.existingContact}); + + @override + State createState() => _AddContactBottomSheetState(); +} + +class _AddContactBottomSheetState extends State { + final controller = Get.put(AddContactController()); + final formKey = GlobalKey(); + + final nameController = TextEditingController(); + final orgController = TextEditingController(); + final addressController = TextEditingController(); + final descriptionController = TextEditingController(); + final tagTextController = TextEditingController(); + + final RxList emailControllers = + [].obs; + final RxList emailLabels = [].obs; + + final RxList phoneControllers = + [].obs; + final RxList phoneLabels = [].obs; + + @override + void initState() { + super.initState(); + controller.resetForm(); + + nameController.text = widget.existingContact?.name ?? ''; + orgController.text = widget.existingContact?.organization ?? ''; + addressController.text = widget.existingContact?.address ?? ''; + descriptionController.text = widget.existingContact?.description ?? ''; + tagTextController.clear(); + + if (widget.existingContact != null) { + emailControllers.clear(); + emailLabels.clear(); + for (var email in widget.existingContact!.contactEmails) { + emailControllers.add(TextEditingController(text: email.emailAddress)); + emailLabels.add((email.label).obs); + } + if (emailControllers.isEmpty) { + emailControllers.add(TextEditingController()); + emailLabels.add('Office'.obs); + } + + phoneControllers.clear(); + phoneLabels.clear(); + for (var phone in widget.existingContact!.contactPhones) { + phoneControllers.add(TextEditingController(text: phone.phoneNumber)); + phoneLabels.add((phone.label).obs); + } + if (phoneControllers.isEmpty) { + phoneControllers.add(TextEditingController()); + phoneLabels.add('Work'.obs); + } + + controller.enteredTags.assignAll( + widget.existingContact!.tags.map((tag) => tag.name).toList(), + ); + + ever(controller.isInitialized, (bool ready) { + if (ready) { + final projectIds = widget.existingContact!.projectIds; + final bucketId = widget.existingContact!.bucketIds.firstOrNull; + final categoryName = widget.existingContact!.contactCategory?.name; + + if (categoryName != null) { + controller.selectedCategory.value = categoryName; + } + + if (projectIds != null) { + final names = projectIds + .map((id) { + return controller.projectsMap.entries + .firstWhereOrNull((e) => e.value == id) + ?.key; + }) + .whereType() + .toList(); + controller.selectedProjects.assignAll(names); + } + if (bucketId != null) { + final name = controller.bucketsMap.entries + .firstWhereOrNull((e) => e.value == bucketId) + ?.key; + if (name != null) { + controller.selectedBucket.value = name; + } + } + } + }); + } else { + emailControllers.add(TextEditingController()); + emailLabels.add('Office'.obs); + phoneControllers.add(TextEditingController()); + phoneLabels.add('Work'.obs); + } + } + + @override + void dispose() { + nameController.dispose(); + orgController.dispose(); + tagTextController.dispose(); + addressController.dispose(); + descriptionController.dispose(); + emailControllers.forEach((e) => e.dispose()); + phoneControllers.forEach((p) => p.dispose()); + Get.delete(); + super.dispose(); + } + + InputDecoration _inputDecoration(String hint) => InputDecoration( + hintText: hint, + hintStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + isDense: true, + ); + + Widget _buildLabeledRow( + String label, + RxString selectedLabel, + List options, + String inputLabel, + TextEditingController controller, + TextInputType inputType, + {VoidCallback? onRemove}) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium(label), + MySpacing.height(8), + _popupSelector( + hint: "Label", + selectedValue: selectedLabel, + options: options), + ], + ), + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium(inputLabel), + MySpacing.height(8), + TextFormField( + controller: controller, + keyboardType: inputType, + maxLength: inputType == TextInputType.phone ? 10 : null, + inputFormatters: inputType == TextInputType.phone + ? [FilteringTextInputFormatter.digitsOnly] + : [], + decoration: _inputDecoration("Enter $inputLabel") + .copyWith(counterText: ""), + validator: (value) { + if (value == null || value.trim().isEmpty) + return "$inputLabel is required"; + final trimmed = value.trim(); + if (inputType == TextInputType.phone) { + if (!RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) { + return "Enter valid phone number"; + } + } + + if (inputType == TextInputType.emailAddress && + !RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$') + .hasMatch(trimmed)) { + return "Enter valid email"; + } + return null; + }, + ), + ], + ), + ), + if (onRemove != null) + Padding( + padding: const EdgeInsets.only(top: 24), + child: IconButton( + icon: const Icon(Icons.remove_circle_outline, color: Colors.red), + onPressed: onRemove, + ), + ), + ], + ); + } + + Widget _buildEmailList() => Column( + children: List.generate(emailControllers.length, (index) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _buildLabeledRow( + "Email Label", + emailLabels[index], + ["Office", "Personal", "Other"], + "Email", + emailControllers[index], + TextInputType.emailAddress, + onRemove: emailControllers.length > 1 + ? () { + emailControllers.removeAt(index); + emailLabels.removeAt(index); + } + : null, + ), + ); + }), + ); + + Widget _buildPhoneList() => Column( + children: List.generate(phoneControllers.length, (index) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _buildLabeledRow( + "Phone Label", + phoneLabels[index], + ["Work", "Mobile", "Other"], + "Phone", + phoneControllers[index], + TextInputType.phone, + onRemove: phoneControllers.length > 1 + ? () { + phoneControllers.removeAt(index); + phoneLabels.removeAt(index); + } + : null, + ), + ); + }), + ); + + Widget _popupSelector({ + required String hint, + required RxString selectedValue, + required List options, + }) { + return Obx(() => GestureDetector( + onTap: () async { + final selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB(100, 300, 100, 0), + items: options.map((option) { + return PopupMenuItem( + value: option, + child: Text(option), + ); + }).toList(), + ); + + if (selected != null) { + selectedValue.value = selected; + } + }, + child: Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + alignment: Alignment.centerLeft, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + selectedValue.value.isNotEmpty ? selectedValue.value : hint, + style: const TextStyle(fontSize: 14), + ), + const Icon(Icons.expand_more, size: 20), + ], + ), + ), + )); + } + + Widget _sectionLabel(String title) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelLarge(title, fontWeight: 600), + MySpacing.height(4), + Divider(thickness: 1, color: Colors.grey.shade200), + ], + ); + + Widget _tagInputSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 48, + child: TextField( + controller: tagTextController, + onChanged: controller.filterSuggestions, + onSubmitted: (value) { + controller.addEnteredTag(value); + tagTextController.clear(); + controller.clearSuggestions(); + }, + decoration: _inputDecoration("Start typing to add tags"), + ), + ), + Obx(() => controller.filteredSuggestions.isEmpty + ? const SizedBox() + : Container( + margin: const EdgeInsets.only(top: 4), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + boxShadow: const [ + BoxShadow(color: Colors.black12, blurRadius: 4) + ], + ), + child: ListView.builder( + shrinkWrap: true, + itemCount: controller.filteredSuggestions.length, + itemBuilder: (context, index) { + final suggestion = controller.filteredSuggestions[index]; + return ListTile( + dense: true, + title: Text(suggestion), + onTap: () { + controller.addEnteredTag(suggestion); + tagTextController.clear(); + controller.clearSuggestions(); + }, + ); + }, + ), + )), + MySpacing.height(8), + Obx(() => Wrap( + spacing: 8, + children: controller.enteredTags + .map((tag) => Chip( + label: Text(tag), + onDeleted: () => controller.removeEnteredTag(tag), + )) + .toList(), + )), + ], + ); + } + + Widget _buildTextField(String label, TextEditingController controller, + {int maxLines = 1}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium(label), + MySpacing.height(8), + TextFormField( + controller: controller, + maxLines: maxLines, + decoration: _inputDecoration("Enter $label"), + validator: (value) => value == null || value.trim().isEmpty + ? "$label is required" + : null, + ), + ], + ); + } + + Widget _buildOrganizationField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium("Organization"), + MySpacing.height(8), + TextField( + controller: orgController, + onChanged: controller.filterOrganizationSuggestions, + decoration: _inputDecoration("Enter organization"), + ), + Obx(() => controller.filteredOrgSuggestions.isEmpty + ? const SizedBox() + : ListView.builder( + shrinkWrap: true, + itemCount: controller.filteredOrgSuggestions.length, + itemBuilder: (context, index) { + final suggestion = controller.filteredOrgSuggestions[index]; + return ListTile( + dense: true, + title: Text(suggestion), + onTap: () { + orgController.text = suggestion; + controller.filteredOrgSuggestions.clear(); + }, + ); + }, + )), + ], + ); + } + + Widget _buildActionButtons() { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + Get.back(); + Get.delete(); + }, + icon: const Icon(Icons.close, color: Colors.red), + label: + MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + ), + ), + ), + MySpacing.width(12), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + if (formKey.currentState!.validate()) { + final emails = emailControllers + .asMap() + .entries + .where((entry) => entry.value.text.trim().isNotEmpty) + .map((entry) => { + "label": emailLabels[entry.key].value, + "emailAddress": entry.value.text.trim(), + }) + .toList(); + + final phones = phoneControllers + .asMap() + .entries + .where((entry) => entry.value.text.trim().isNotEmpty) + .map((entry) => { + "label": phoneLabels[entry.key].value, + "phoneNumber": entry.value.text.trim(), + }) + .toList(); + + controller.submitContact( + id: widget.existingContact?.id, + name: nameController.text.trim(), + organization: orgController.text.trim(), + emails: emails, + phones: phones, + address: addressController.text.trim(), + description: descriptionController.text.trim(), + ); + } + }, + icon: const Icon(Icons.check_circle_outline, color: Colors.white), + label: + MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Obx(() { + if (!controller.isInitialized.value) { + return const Center(child: CircularProgressIndicator()); + } + + return SingleChildScrollView( + padding: MediaQuery.of(context).viewInsets, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: MyText.titleMedium( + widget.existingContact != null + ? "Edit Contact" + : "Create New Contact", + fontWeight: 700, + ), + ), + MySpacing.height(24), + _sectionLabel("Basic Info"), + MySpacing.height(16), + _buildTextField("Name", nameController), + MySpacing.height(16), + _buildOrganizationField(), + MySpacing.height(24), + _sectionLabel("Contact Info"), + MySpacing.height(16), + Obx(() => _buildEmailList()), + TextButton.icon( + onPressed: () { + emailControllers.add(TextEditingController()); + emailLabels.add('Office'.obs); + }, + icon: const Icon(Icons.add), + label: const Text("Add Email"), + ), + Obx(() => _buildPhoneList()), + TextButton.icon( + onPressed: () { + phoneControllers.add(TextEditingController()); + phoneLabels.add('Work'.obs); + }, + icon: const Icon(Icons.add), + label: const Text("Add Phone"), + ), + MySpacing.height(24), + _sectionLabel("Other Details"), + MySpacing.height(16), + MyText.labelMedium("Category"), + MySpacing.height(8), + _popupSelector( + hint: "Select Category", + selectedValue: controller.selectedCategory, + options: controller.categories, + ), + MySpacing.height(16), + MyText.labelMedium("Select Projects"), + MySpacing.height(8), + GestureDetector( + onTap: () async { + await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Select Projects'), + content: Obx(() { + return SizedBox( + width: double.maxFinite, + child: ListView( + shrinkWrap: true, + children: + controller.globalProjects.map((project) { + final isSelected = controller + .selectedProjects + .contains(project); + return Theme( + data: Theme.of(context).copyWith( + unselectedWidgetColor: Colors + .black, // checkbox border when not selected + checkboxTheme: CheckboxThemeData( + fillColor: MaterialStateProperty + .resolveWith((states) { + if (states.contains( + MaterialState.selected)) { + return Colors + .white; // fill when selected + } + return Colors.transparent; + }), + checkColor: MaterialStateProperty.all( + Colors.black), // check mark color + side: const BorderSide( + color: Colors.black, + width: 2), // border color + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(4), + ), + ), + ), + child: CheckboxListTile( + dense: true, + title: Text(project), + value: isSelected, + onChanged: (bool? selected) { + if (selected == true) { + controller.selectedProjects + .add(project); + } else { + controller.selectedProjects + .remove(project); + } + }, + ), + ); + }).toList(), + ), + ); + }), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Done'), + ), + ], + ); + }, + ); + }, + child: Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + alignment: Alignment.centerLeft, + child: Obx(() { + final selected = controller.selectedProjects; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + selected.isEmpty + ? "Select Projects" + : selected.join(', '), + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14), + ), + ), + const Icon(Icons.expand_more, size: 20), + ], + ); + }), + ), + ), + MySpacing.height(16), + MyText.labelMedium("Select Bucket"), + MySpacing.height(8), + _popupSelector( + hint: "Select Bucket", + selectedValue: controller.selectedBucket, + options: controller.buckets, + ), + MySpacing.height(16), + MyText.labelMedium("Tags"), + MySpacing.height(8), + _tagInputSection(), + MySpacing.height(16), + _buildTextField("Address", addressController, maxLines: 2), + MySpacing.height(16), + _buildTextField("Description", descriptionController, + maxLines: 2), + MySpacing.height(24), + _buildActionButtons(), + ], + ), + ), + ), + ), + ); + }); + } +} diff --git a/lib/model/directory/contact_bucket_list_model.dart b/lib/model/directory/contact_bucket_list_model.dart new file mode 100644 index 0000000..8a9bc42 --- /dev/null +++ b/lib/model/directory/contact_bucket_list_model.dart @@ -0,0 +1,57 @@ +class ContactBucket { + final String id; + final String name; + final String description; + final CreatedBy createdBy; + final List employeeIds; + final int numberOfContacts; + + ContactBucket({ + required this.id, + required this.name, + required this.description, + required this.createdBy, + required this.employeeIds, + required this.numberOfContacts, + }); + + factory ContactBucket.fromJson(Map json) { + return ContactBucket( + id: json['id'], + name: json['name'], + description: json['description'], + createdBy: CreatedBy.fromJson(json['createdBy']), + employeeIds: List.from(json['employeeIds']), + numberOfContacts: json['numberOfContacts'], + ); + } +} + +class CreatedBy { + final String id; + final String firstName; + final String lastName; + final String? photo; + final String jobRoleId; + final String jobRoleName; + + CreatedBy({ + required this.id, + required this.firstName, + required this.lastName, + this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory CreatedBy.fromJson(Map json) { + return CreatedBy( + id: json['id'], + firstName: json['firstName'], + lastName: json['lastName'], + photo: json['photo'], + jobRoleId: json['jobRoleId'], + jobRoleName: json['jobRoleName'], + ); + } +} diff --git a/lib/model/directory/contact_category_model.dart b/lib/model/directory/contact_category_model.dart new file mode 100644 index 0000000..4d1dd85 --- /dev/null +++ b/lib/model/directory/contact_category_model.dart @@ -0,0 +1,65 @@ +class ContactCategoryResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + ContactCategoryResponse({ + required this.success, + required this.message, + required this.data, + required this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory ContactCategoryResponse.fromJson(Map json) { + return ContactCategoryResponse( + success: json['success'], + message: json['message'], + data: List.from( + json['data'].map((x) => ContactCategory.fromJson(x)), + ), + errors: json['errors'], + statusCode: json['statusCode'], + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map toJson() => { + 'success': success, + 'message': message, + 'data': data.map((x) => x.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; +} + +class ContactCategory { + final String id; + final String name; + final String description; + + ContactCategory({ + required this.id, + required this.name, + required this.description, + }); + + factory ContactCategory.fromJson(Map json) { + return ContactCategory( + id: json['id'], + name: json['name'], + description: json['description'], + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'description': description, + }; +} diff --git a/lib/model/directory/contact_model.dart b/lib/model/directory/contact_model.dart new file mode 100644 index 0000000..e03e61b --- /dev/null +++ b/lib/model/directory/contact_model.dart @@ -0,0 +1,135 @@ +class ContactModel { + final String id; + final List? projectIds; + final String name; + final List contactPhones; + final List contactEmails; + final ContactCategory? contactCategory; + final List bucketIds; + final String description; + final String organization; + final String address; + final List tags; + + ContactModel({ + required this.id, + required this.projectIds, + required this.name, + required this.contactPhones, + required this.contactEmails, + required this.contactCategory, + required this.bucketIds, + required this.description, + required this.organization, + required this.address, + required this.tags, + }); + + factory ContactModel.fromJson(Map json) { + return ContactModel( + id: json['id'], + projectIds: (json['projectIds'] as List?)?.map((e) => e as String).toList(), + name: json['name'], + contactPhones: (json['contactPhones'] as List) + .map((e) => ContactPhone.fromJson(e)) + .toList(), + contactEmails: (json['contactEmails'] as List) + .map((e) => ContactEmail.fromJson(e)) + .toList(), + contactCategory: json['contactCategory'] != null + ? ContactCategory.fromJson(json['contactCategory']) + : null, + bucketIds: (json['bucketIds'] as List).map((e) => e as String).toList(), + description: json['description'], + organization: json['organization'], + address: json['address'], + tags: (json['tags'] as List).map((e) => Tag.fromJson(e)).toList(), + ); + } +} + +class ContactPhone { + final String id; + final String label; + final String phoneNumber; + final String contactId; + + ContactPhone({ + required this.id, + required this.label, + required this.phoneNumber, + required this.contactId, + }); + + factory ContactPhone.fromJson(Map json) { + return ContactPhone( + id: json['id'], + label: json['label'], + phoneNumber: json['phoneNumber'], + contactId: json['contactId'], + ); + } +} + +class ContactEmail { + final String id; + final String label; + final String emailAddress; + final String contactId; + + ContactEmail({ + required this.id, + required this.label, + required this.emailAddress, + required this.contactId, + }); + + factory ContactEmail.fromJson(Map json) { + return ContactEmail( + id: json['id'], + label: json['label'], + emailAddress: json['emailAddress'], + contactId: json['contactId'], + ); + } +} + +class ContactCategory { + final String id; + final String name; + final String description; + + ContactCategory({ + required this.id, + required this.name, + required this.description, + }); + + factory ContactCategory.fromJson(Map json) { + return ContactCategory( + id: json['id'], + name: json['name'], + description: json['description'], + ); + } +} + +class Tag { + final String id; + final String name; + final String description; + + Tag({ + required this.id, + required this.name, + required this.description, + }); + + factory Tag.fromJson(Map json) { + return Tag( + id: json['id'], + name: json['name'], + description: json['description'], + ); + } +} diff --git a/lib/model/directory/contact_profile_comment_model.dart b/lib/model/directory/contact_profile_comment_model.dart new file mode 100644 index 0000000..dc3f8ab --- /dev/null +++ b/lib/model/directory/contact_profile_comment_model.dart @@ -0,0 +1,245 @@ +class ContactProfileResponse { + final bool success; + final String message; + final ContactData data; + final int statusCode; + final String timestamp; + + ContactProfileResponse({ + required this.success, + required this.message, + required this.data, + required this.statusCode, + required this.timestamp, + }); + + factory ContactProfileResponse.fromJson(Map json) { + return ContactProfileResponse( + success: json['success'], + message: json['message'], + data: ContactData.fromJson(json['data']), + statusCode: json['statusCode'], + timestamp: json['timestamp'], + ); + } +} + +class ContactData { + final String id; + final String name; + final String? description; + final String organization; + final String address; + final String createdAt; + final String updatedAt; + final User createdBy; + final User updatedBy; + final List contactPhones; + final List contactEmails; + final ContactCategory? contactCategory; + final List projects; + final List buckets; + final List tags; + + ContactData({ + required this.id, + required this.name, + this.description, + required this.organization, + required this.address, + required this.createdAt, + required this.updatedAt, + required this.createdBy, + required this.updatedBy, + required this.contactPhones, + required this.contactEmails, + this.contactCategory, + required this.projects, + required this.buckets, + required this.tags, + }); + + factory ContactData.fromJson(Map json) { + return ContactData( + id: json['id'], + name: json['name'], + description: json['description'], + organization: json['organization'], + address: json['address'], + createdAt: json['createdAt'], + updatedAt: json['updatedAt'], + createdBy: User.fromJson(json['createdBy']), + updatedBy: User.fromJson(json['updatedBy']), + contactPhones: (json['contactPhones'] as List) + .map((e) => ContactPhone.fromJson(e)) + .toList(), + contactEmails: (json['contactEmails'] as List) + .map((e) => ContactEmail.fromJson(e)) + .toList(), + contactCategory: json['contactCategory'] != null + ? ContactCategory.fromJson(json['contactCategory']) + : null, + projects: (json['projects'] as List) + .map((e) => ProjectInfo.fromJson(e)) + .toList(), + buckets: + (json['buckets'] as List).map((e) => Bucket.fromJson(e)).toList(), + tags: (json['tags'] as List).map((e) => Tag.fromJson(e)).toList(), + ); + } +} + +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, + this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory User.fromJson(Map json) { + return User( + id: json['id'], + firstName: json['firstName'], + lastName: json['lastName'], + photo: json['photo'], + jobRoleId: json['jobRoleId'], + jobRoleName: json['jobRoleName'], + ); + } +} + +class ContactPhone { + final String id; + final String label; + final String phoneNumber; + final String contactId; + + ContactPhone({ + required this.id, + required this.label, + required this.phoneNumber, + required this.contactId, + }); + + factory ContactPhone.fromJson(Map json) { + return ContactPhone( + id: json['id'], + label: json['label'], + phoneNumber: json['phoneNumber'], + contactId: json['contactId'], + ); + } +} + +class ContactEmail { + final String id; + final String label; + final String emailAddress; + final String contactId; + + ContactEmail({ + required this.id, + required this.label, + required this.emailAddress, + required this.contactId, + }); + + factory ContactEmail.fromJson(Map json) { + return ContactEmail( + id: json['id'], + label: json['label'], + emailAddress: json['emailAddress'], + contactId: json['contactId'], + ); + } +} + +class ContactCategory { + final String id; + final String name; + final String? description; + + ContactCategory({ + required this.id, + required this.name, + this.description, + }); + + factory ContactCategory.fromJson(Map json) { + return ContactCategory( + id: json['id'], + name: json['name'], + description: json['description'], + ); + } +} + +class ProjectInfo { + final String id; + final String name; + + ProjectInfo({ + required this.id, + required this.name, + }); + + factory ProjectInfo.fromJson(Map json) { + return ProjectInfo( + id: json['id'], + name: json['name'], + ); + } +} + +class Bucket { + final String id; + final String name; + final String description; + final User createdBy; + + Bucket({ + required this.id, + required this.name, + required this.description, + required this.createdBy, + }); + + factory Bucket.fromJson(Map json) { + return Bucket( + id: json['id'], + name: json['name'], + description: json['description'], + createdBy: User.fromJson(json['createdBy']), + ); + } +} + +class Tag { + final String id; + final String name; + final String? description; + + Tag({ + required this.id, + required this.name, + this.description, + }); + + factory Tag.fromJson(Map json) { + return Tag( + id: json['id'], + name: json['name'], + description: json['description'], + ); + } +} diff --git a/lib/model/directory/contact_tag_model.dart b/lib/model/directory/contact_tag_model.dart new file mode 100644 index 0000000..6a939dc --- /dev/null +++ b/lib/model/directory/contact_tag_model.dart @@ -0,0 +1,65 @@ +class ContactTagResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + ContactTagResponse({ + required this.success, + required this.message, + required this.data, + required this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory ContactTagResponse.fromJson(Map json) { + return ContactTagResponse( + success: json['success'], + message: json['message'], + data: List.from( + json['data'].map((x) => ContactTag.fromJson(x)), + ), + errors: json['errors'], + statusCode: json['statusCode'], + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map toJson() => { + 'success': success, + 'message': message, + 'data': data.map((x) => x.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; +} + +class ContactTag { + final String id; + final String name; + final String description; + + ContactTag({ + required this.id, + required this.name, + required this.description, + }); + + factory ContactTag.fromJson(Map json) { + return ContactTag( + id: json['id'], + name: json['name'], + description: json['description'] ?? '', + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'description': description, + }; +} diff --git a/lib/model/directory/directory_comment_model.dart b/lib/model/directory/directory_comment_model.dart new file mode 100644 index 0000000..562e6dc --- /dev/null +++ b/lib/model/directory/directory_comment_model.dart @@ -0,0 +1,145 @@ +class DirectoryCommentResponse { + final bool success; + final String message; + final List data; + final int statusCode; + final String? timestamp; + + DirectoryCommentResponse({ + required this.success, + required this.message, + required this.data, + required this.statusCode, + this.timestamp, + }); + + factory DirectoryCommentResponse.fromJson(Map json) { + return DirectoryCommentResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: (json['data'] as List?) + ?.map((e) => DirectoryComment.fromJson(e)) + .toList() ?? + [], + statusCode: json['statusCode'] ?? 0, + timestamp: json['timestamp'], + ); + } +} + +class DirectoryComment { + final String id; + final String note; + final String contactName; + final String organizationName; + final DateTime createdAt; + final CommentUser createdBy; + final DateTime? updatedAt; + final CommentUser? updatedBy; + final String contactId; + final bool isActive; + + DirectoryComment({ + required this.id, + required this.note, + required this.contactName, + required this.organizationName, + required this.createdAt, + required this.createdBy, + this.updatedAt, + this.updatedBy, + required this.contactId, + required this.isActive, + }); + + factory DirectoryComment.fromJson(Map json) { + return DirectoryComment( + id: json['id'] ?? '', + note: json['note'] ?? '', + contactName: json['contactName'] ?? '', + organizationName: json['organizationName'] ?? '', + createdAt: DateTime.parse(json['createdAt']), + createdBy: CommentUser.fromJson(json['createdBy']), + updatedAt: + json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null, + updatedBy: json['updatedBy'] != null + ? CommentUser.fromJson(json['updatedBy']) + : null, + contactId: json['contactId'] ?? '', + isActive: json['isActive'] ?? true, + ); + } + + DirectoryComment copyWith({ + String? id, + String? note, + String? contactName, + String? organizationName, + DateTime? createdAt, + CommentUser? createdBy, + DateTime? updatedAt, + CommentUser? updatedBy, + String? contactId, + bool? isActive, + }) { + return DirectoryComment( + id: id ?? this.id, + note: note ?? this.note, + contactName: contactName ?? this.contactName, + organizationName: organizationName ?? this.organizationName, + createdAt: createdAt ?? this.createdAt, + createdBy: createdBy ?? this.createdBy, + updatedAt: updatedAt ?? this.updatedAt, + updatedBy: updatedBy ?? this.updatedBy, + contactId: contactId ?? this.contactId, + isActive: isActive ?? this.isActive, + ); + } +} + +class CommentUser { + final String id; + final String firstName; + final String lastName; + final String? photo; + final String jobRoleId; + final String jobRoleName; + + CommentUser({ + required this.id, + required this.firstName, + required this.lastName, + this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory CommentUser.fromJson(Map json) { + return CommentUser( + id: json['id'] ?? '', + firstName: json['firstName'] ?? '', + lastName: json['lastName'] ?? '', + photo: json['photo'], + jobRoleId: json['jobRoleId'] ?? '', + jobRoleName: json['jobRoleName'] ?? '', + ); + } + + CommentUser copyWith({ + String? id, + String? firstName, + String? lastName, + String? photo, + String? jobRoleId, + String? jobRoleName, + }) { + return CommentUser( + id: id ?? this.id, + firstName: firstName ?? this.firstName, + lastName: lastName ?? this.lastName, + photo: photo ?? this.photo, + jobRoleId: jobRoleId ?? this.jobRoleId, + jobRoleName: jobRoleName ?? this.jobRoleName, + ); + } +} diff --git a/lib/model/directory/directory_filter_bottom_sheet.dart b/lib/model/directory/directory_filter_bottom_sheet.dart new file mode 100644 index 0000000..e39f689 --- /dev/null +++ b/lib/model/directory/directory_filter_bottom_sheet.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/directory/directory_controller.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +class DirectoryFilterBottomSheet extends StatelessWidget { + const DirectoryFilterBottomSheet({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.find(); + + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 20, + top: 12, + left: 16, + right: 16, + ), + child: Obx(() { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + /// Drag handle + Center( + child: Container( + height: 5, + width: 50, + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2.5), + ), + ), + ), + + /// Title + Center( + child: MyText.titleMedium( + "Filter Contacts", + fontWeight: 700, + ), + ), + + const SizedBox(height: 24), + + /// Categories + if (controller.contactCategories.isNotEmpty) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodyMedium("Categories", fontWeight: 600), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 2, + runSpacing: 0, + children: controller.contactCategories.map((category) { + final selected = + controller.selectedCategories.contains(category.id); + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + child: FilterChip( + label: MyText.bodySmall( + category.name, + color: selected ? Colors.white : Colors.black87, + ), + selected: selected, + onSelected: (_) => + controller.toggleCategory(category.id), + selectedColor: Colors.indigo, + backgroundColor: Colors.grey.shade200, + checkmarkColor: Colors.white, + ), + ); + }).toList(), + ), + const SizedBox(height: 12), + ], + + /// Buckets + if (controller.contactBuckets.isNotEmpty) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodyMedium("Buckets", fontWeight: 600), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 2, + runSpacing: 0, + children: controller.contactBuckets.map((bucket) { + final selected = + controller.selectedBuckets.contains(bucket.id); + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + child: FilterChip( + label: MyText.bodySmall( + bucket.name, + color: selected ? Colors.white : Colors.black87, + ), + selected: selected, + onSelected: (_) => controller.toggleBucket(bucket.id), + selectedColor: Colors.teal, + backgroundColor: Colors.grey.shade200, + checkmarkColor: Colors.white, + ), + ); + }).toList(), + ), + ], + + const SizedBox(height: 12), + + /// Action Buttons + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + OutlinedButton.icon( + onPressed: () { + controller.selectedCategories.clear(); + controller.selectedBuckets.clear(); + controller.searchQuery.value = ''; + controller.applyFilters(); + Get.back(); + }, + icon: const Icon(Icons.refresh, color: Colors.red), + label: MyText.bodyMedium("Clear", color: Colors.red), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 7), + ), + ), + ElevatedButton.icon( + onPressed: () { + controller.applyFilters(); + Get.back(); + }, + icon: const Icon(Icons.check_circle_outline), + label: MyText.bodyMedium("Apply", color: Colors.white), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 7), + ), + ), + ], + ), + const SizedBox(height: 10), + ], + ), + ); + }), + ); + } +} diff --git a/lib/model/directory/note_list_response_model.dart b/lib/model/directory/note_list_response_model.dart new file mode 100644 index 0000000..e92283c --- /dev/null +++ b/lib/model/directory/note_list_response_model.dart @@ -0,0 +1,142 @@ +class NoteListResponseModel { + final bool success; + final String message; + final NotePaginationData data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + NoteListResponseModel({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory NoteListResponseModel.fromJson(Map json) { + return NoteListResponseModel( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: NotePaginationData.fromJson(json['data']), + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.parse(json['timestamp']), + ); + } +} + +class NotePaginationData { + final int currentPage; + final int pageSize; + final int totalPages; + final int totalRecords; + final List data; + + NotePaginationData({ + required this.currentPage, + required this.pageSize, + required this.totalPages, + required this.totalRecords, + required this.data, + }); + + factory NotePaginationData.fromJson(Map json) { + return NotePaginationData( + currentPage: json['currentPage'] ?? 0, + pageSize: json['pageSize'] ?? 0, + totalPages: json['totalPages'] ?? 0, + totalRecords: json['totalRecords'] ?? 0, + data: List.from( + (json['data'] ?? []).map((x) => NoteModel.fromJson(x)), + ), + ); + } +} + +class NoteModel { + final String id; + final String note; + final String contactName; + final String organizationName; + final DateTime createdAt; + final UserModel createdBy; + final DateTime? updatedAt; + final UserModel? updatedBy; + final String contactId; + final bool isActive; + + NoteModel({ + required this.id, + required this.note, + required this.contactName, + required this.organizationName, + required this.createdAt, + required this.createdBy, + this.updatedAt, + this.updatedBy, + required this.contactId, + required this.isActive, + }); + NoteModel copyWith({String? note}) => NoteModel( + id: id, + note: note ?? this.note, + contactName: contactName, + organizationName: organizationName, + createdAt: createdAt, + createdBy: createdBy, + updatedAt: updatedAt, + updatedBy: updatedBy, + contactId: contactId, + isActive: isActive, + ); + + factory NoteModel.fromJson(Map json) { + return NoteModel( + id: json['id'] ?? '', + note: json['note'] ?? '', + contactName: json['contactName'] ?? '', + organizationName: json['organizationName'] ?? '', + createdAt: DateTime.parse(json['createdAt']), + createdBy: UserModel.fromJson(json['createdBy']), + updatedAt: json['updatedAt'] != null + ? DateTime.tryParse(json['updatedAt']) + : null, + updatedBy: json['updatedBy'] != null + ? UserModel.fromJson(json['updatedBy']) + : null, + contactId: json['contactId'] ?? '', + isActive: json['isActive'] ?? true, + ); + } +} + +class UserModel { + final String id; + final String firstName; + final String lastName; + final String? photo; + final String jobRoleId; + final String jobRoleName; + + UserModel({ + required this.id, + required this.firstName, + required this.lastName, + this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory UserModel.fromJson(Map json) { + return UserModel( + id: json['id'] ?? '', + firstName: json['firstName'] ?? '', + lastName: json['lastName'] ?? '', + photo: json['photo'], + jobRoleId: json['jobRoleId'] ?? '', + jobRoleName: json['jobRoleName'] ?? '', + ); + } +} diff --git a/lib/model/directory/organization_list_model.dart b/lib/model/directory/organization_list_model.dart new file mode 100644 index 0000000..8507025 --- /dev/null +++ b/lib/model/directory/organization_list_model.dart @@ -0,0 +1,25 @@ +class OrganizationListModel { + final bool success; + final String message; + final List data; + final int statusCode; + final String timestamp; + + OrganizationListModel({ + required this.success, + required this.message, + required this.data, + required this.statusCode, + required this.timestamp, + }); + + factory OrganizationListModel.fromJson(Map json) { + return OrganizationListModel( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: List.from(json['data'] ?? []), + statusCode: json['statusCode'] ?? 0, + timestamp: json['timestamp'] ?? '', + ); + } +} diff --git a/lib/model/employees/add_employee_bottom_sheet.dart b/lib/model/employees/add_employee_bottom_sheet.dart index fa0c8d7..52d0cf5 100644 --- a/lib/model/employees/add_employee_bottom_sheet.dart +++ b/lib/model/employees/add_employee_bottom_sheet.dart @@ -13,7 +13,8 @@ class AddEmployeeBottomSheet extends StatefulWidget { State createState() => _AddEmployeeBottomSheetState(); } -class _AddEmployeeBottomSheetState extends State with UIMixin { +class _AddEmployeeBottomSheetState extends State + with UIMixin { final AddEmployeeController _controller = Get.put(AddEmployeeController()); late TextEditingController genderController; @@ -27,7 +28,8 @@ class _AddEmployeeBottomSheetState extends State with UI } RelativeRect _popupMenuPosition(BuildContext context) { - final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox; + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; return RelativeRect.fromLTRB(100, 300, overlay.size.width - 100, 0); } @@ -135,8 +137,14 @@ class _AddEmployeeBottomSheetState extends State with UI child: Container( decoration: BoxDecoration( color: theme.cardColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, -2))], + borderRadius: + const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 12, + offset: Offset(0, -2)) + ], ), child: Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), @@ -153,7 +161,8 @@ class _AddEmployeeBottomSheetState extends State with UI ), ), MySpacing.height(12), - Text("Add Employee", style: MyTextStyle.titleLarge(fontWeight: 700)), + Text("Add Employee", + style: MyTextStyle.titleLarge(fontWeight: 700)), MySpacing.height(24), Form( key: _controller.basicValidator.formKey, @@ -166,16 +175,20 @@ class _AddEmployeeBottomSheetState extends State with UI label: "First Name", hint: "e.g., John", icon: Icons.person, - controller: _controller.basicValidator.getController('first_name')!, - validator: _controller.basicValidator.getValidation('first_name'), + controller: _controller.basicValidator + .getController('first_name')!, + validator: _controller.basicValidator + .getValidation('first_name'), ), MySpacing.height(16), _inputWithIcon( label: "Last Name", hint: "e.g., Doe", icon: Icons.person_outline, - controller: _controller.basicValidator.getController('last_name')!, - validator: _controller.basicValidator.getValidation('last_name'), + controller: _controller.basicValidator + .getController('last_name')!, + validator: _controller.basicValidator + .getValidation('last_name'), ), MySpacing.height(16), _sectionLabel("Contact Details"), @@ -185,7 +198,8 @@ class _AddEmployeeBottomSheetState extends State with UI Row( children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 14), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(12), @@ -193,7 +207,8 @@ class _AddEmployeeBottomSheetState extends State with UI ), child: PopupMenuButton>( onSelected: (country) { - _controller.selectedCountryCode = country['code']!; + _controller.selectedCountryCode = + country['code']!; _controller.update(); }, itemBuilder: (context) => [ @@ -204,11 +219,14 @@ class _AddEmployeeBottomSheetState extends State with UI height: 200, width: 100, child: ListView( - children: _controller.countries.map((country) { + children: _controller.countries + .map((country) { return ListTile( dense: true, - title: Text("${country['name']} (${country['code']})"), - onTap: () => Navigator.pop(context, country), + title: Text( + "${country['name']} (${country['code']})"), + onTap: () => + Navigator.pop(context, country), ); }).toList(), ), @@ -226,31 +244,42 @@ class _AddEmployeeBottomSheetState extends State with UI MySpacing.width(12), Expanded( child: TextFormField( - controller: _controller.basicValidator.getController('phone_number'), + controller: _controller.basicValidator + .getController('phone_number'), validator: (value) { if (value == null || value.trim().isEmpty) { return "Phone number is required"; } final digitsOnly = value.trim(); - final minLength = _controller.minDigitsPerCountry[_controller.selectedCountryCode] ?? 7; - final maxLength = _controller.maxDigitsPerCountry[_controller.selectedCountryCode] ?? 15; + final minLength = _controller + .minDigitsPerCountry[ + _controller.selectedCountryCode] ?? + 7; + final maxLength = _controller + .maxDigitsPerCountry[ + _controller.selectedCountryCode] ?? + 15; - if (!RegExp(r'^[0-9]+$').hasMatch(digitsOnly)) { + if (!RegExp(r'^[0-9]+$') + .hasMatch(digitsOnly)) { return "Only digits allowed"; } - if (digitsOnly.length < minLength || digitsOnly.length > maxLength) { + if (digitsOnly.length < minLength || + digitsOnly.length > maxLength) { return "Between $minLength–$maxLength digits"; } return null; }, keyboardType: TextInputType.phone, - decoration: _inputDecoration("e.g., 9876543210").copyWith( + decoration: _inputDecoration("e.g., 9876543210") + .copyWith( suffixIcon: IconButton( icon: const Icon(Icons.contacts), - onPressed: () => _controller.pickContact(context), + onPressed: () => + _controller.pickContact(context), ), ), ), @@ -268,9 +297,11 @@ class _AddEmployeeBottomSheetState extends State with UI child: TextFormField( readOnly: true, controller: TextEditingController( - text: _controller.selectedGender?.name.capitalizeFirst, + text: _controller + .selectedGender?.name.capitalizeFirst, ), - decoration: _inputDecoration("Select Gender").copyWith( + decoration: + _inputDecoration("Select Gender").copyWith( suffixIcon: const Icon(Icons.expand_more), ), ), @@ -286,10 +317,14 @@ class _AddEmployeeBottomSheetState extends State with UI readOnly: true, controller: TextEditingController( text: _controller.roles.firstWhereOrNull( - (role) => role['id'] == _controller.selectedRoleId, - )?['name'] ?? "", + (role) => + role['id'] == + _controller.selectedRoleId, + )?['name'] ?? + "", ), - decoration: _inputDecoration("Select Role").copyWith( + decoration: + _inputDecoration("Select Role").copyWith( suffixIcon: const Icon(Icons.expand_more), ), ), @@ -301,11 +336,16 @@ class _AddEmployeeBottomSheetState extends State with UI Expanded( child: OutlinedButton.icon( onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.close, size: 18), - label: MyText.bodyMedium("Cancel", fontWeight: 600), + icon: + const Icon(Icons.close, color: Colors.red), + label: MyText.bodyMedium("Cancel", + color: Colors.red, fontWeight: 600), style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.grey), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 14), ), ), ), @@ -313,23 +353,36 @@ class _AddEmployeeBottomSheetState extends State with UI Expanded( child: ElevatedButton.icon( onPressed: () async { - if (_controller.basicValidator.validateForm()) { - final success = await _controller.createEmployees(); + if (_controller.basicValidator + .validateForm()) { + final success = + await _controller.createEmployees(); if (success) { - final employeeController = Get.find(); - final projectId = employeeController.selectedProjectId; + final employeeController = + Get.find(); + final projectId = + employeeController.selectedProjectId; if (projectId == null) { - await employeeController.fetchAllEmployees(); + await employeeController + .fetchAllEmployees(); } else { - await employeeController.fetchEmployeesByProject(projectId); + await employeeController + .fetchEmployeesByProject(projectId); } - employeeController.update(['employee_screen_controller']); + employeeController.update( + ['employee_screen_controller']); - _controller.basicValidator.getController("first_name")?.clear(); - _controller.basicValidator.getController("last_name")?.clear(); - _controller.basicValidator.getController("phone_number")?.clear(); + _controller.basicValidator + .getController("first_name") + ?.clear(); + _controller.basicValidator + .getController("last_name") + ?.clear(); + _controller.basicValidator + .getController("phone_number") + ?.clear(); _controller.selectedGender = null; _controller.selectedRoleId = null; _controller.update(); @@ -338,11 +391,16 @@ class _AddEmployeeBottomSheetState extends State with UI } } }, - icon: const Icon(Icons.check, size: 18), - label: MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600), + icon: const Icon(Icons.check_circle_outline, + color: Colors.white), + label: MyText.bodyMedium("Save", + color: Colors.white, fontWeight: 600), style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueAccent, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric( + horizontal: 28, vertical: 14), ), ), ), diff --git a/lib/routes.dart b/lib/routes.dart index 084a465..391eeb9 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -16,6 +16,7 @@ import 'package:marco/view/employees/employees_screen.dart'; import 'package:marco/view/auth/login_option_screen.dart'; import 'package:marco/view/auth/mpin_screen.dart'; import 'package:marco/view/auth/mpin_auth_screen.dart'; +import 'package:marco/view/directory/directory_main_screen.dart'; class AuthMiddleware extends GetMiddleware { @override @@ -60,17 +61,19 @@ getPageRoute() { name: '/dashboard/daily-task-progress', page: () => DailyProgressReportScreen(), middlewares: [AuthMiddleware()]), + GetPage( + name: '/dashboard/directory-main-page', + page: () => DirectoryMainScreen(), + middlewares: [AuthMiddleware()]), // Authentication GetPage(name: '/auth/login', page: () => LoginScreen()), GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()), - GetPage(name: '/auth/mpin', page: () => MPINScreen()), - GetPage(name: '/auth/mpin-auth', page: () => MPINAuthScreen()), + GetPage(name: '/auth/mpin', page: () => MPINScreen()), + GetPage(name: '/auth/mpin-auth', page: () => MPINAuthScreen()), GetPage( name: '/auth/register_account', page: () => const RegisterAccountScreen()), - GetPage( - name: '/auth/forgot_password', - page: () => ForgotPasswordScreen()), + GetPage(name: '/auth/forgot_password', page: () => ForgotPasswordScreen()), GetPage( name: '/auth/reset_password', page: () => const ResetPasswordScreen()), // Error diff --git a/lib/view/auth/email_login_form.dart b/lib/view/auth/email_login_form.dart index 03db388..eff0222 100644 --- a/lib/view/auth/email_login_form.dart +++ b/lib/view/auth/email_login_form.dart @@ -8,7 +8,7 @@ import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; -import 'package:marco/view/auth/request_demo_bottom_sheet.dart'; + import 'package:marco/helpers/services/api_endpoints.dart'; class EmailLoginForm extends StatefulWidget { @@ -136,22 +136,7 @@ class _EmailLoginFormState extends State with UIMixin { ), ), ), - MySpacing.height(16), - Center( - child: MyButton.text( - onPressed: () { - OrganizationFormBottomSheet.show(context); - }, - elevation: 0, - padding: MySpacing.xy(12, 8), - splashColor: contentTheme.secondary.withAlpha(30), - child: MyText.bodySmall( - "Request a Demo", - color: contentTheme.brandRed, - fontWeight: 600, - ), - ), - ), + ], ), ); diff --git a/lib/view/auth/forgot_password_screen.dart b/lib/view/auth/forgot_password_screen.dart index e6d0c64..76a9330 100644 --- a/lib/view/auth/forgot_password_screen.dart +++ b/lib/view/auth/forgot_password_screen.dart @@ -1,15 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:get/get.dart'; import 'package:marco/controller/auth/forgot_password_controller.dart'; import 'package:marco/helpers/widgets/my_button.dart'; -import 'package:marco/helpers/widgets/my_text_style.dart'; -import 'package:marco/images.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/services/api_endpoints.dart'; import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/images.dart'; class ForgotPasswordScreen extends StatefulWidget { const ForgotPasswordScreen({super.key}); @@ -19,208 +18,273 @@ class ForgotPasswordScreen extends StatefulWidget { } class _ForgotPasswordScreenState extends State - with UIMixin { + with UIMixin, SingleTickerProviderStateMixin { final ForgotPasswordController controller = Get.put(ForgotPasswordController()); + late AnimationController _controller; + late Animation _logoAnimation; + bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); bool _isLoading = false; - void _handleForgotPassword() async { - setState(() { - _isLoading = true; - }); + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _logoAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeOutBack, + ); + _controller.forward(); + } + @override + void dispose() { + _controller.dispose(); + Get.delete(); + super.dispose(); + } + + Future _handleForgotPassword() async { + setState(() => _isLoading = true); await controller.onForgotPassword(); - - setState(() { - _isLoading = false; - }); + setState(() => _isLoading = false); } @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: contentTheme.brandRed, - body: SafeArea( - child: LayoutBuilder(builder: (context, constraints) { - return Column( - children: [ - const SizedBox(height: 24), - _buildHeader(), - const SizedBox(height: 16), - _buildWelcomeTextsAndChips(), - const SizedBox(height: 16), - Expanded( - child: Container( - width: double.infinity, - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: - BorderRadius.vertical(top: Radius.circular(32)), - ), - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: 24, vertical: 32), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 120, + body: Stack( + children: [ + const _RedWaveBackground(), + SafeArea( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 24), + ScaleTransition( + scale: _logoAnimation, + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], ), - child: Form( - key: controller.basicValidator.formKey, + padding: const EdgeInsets.all(20), + child: Image.asset(Images.logoDark), + ), + ), + const SizedBox(height: 8), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), child: Column( - crossAxisAlignment: CrossAxisAlignment.center, children: [ - MyText.titleLarge( - 'Forgot Password', - fontWeight: 700, + const SizedBox(height: 12), + MyText( + "Welcome to Marco", + fontSize: 24, + fontWeight: 800, color: Colors.black87, + textAlign: TextAlign.center, ), - const SizedBox(height: 8), - MyText.bodyMedium( - "Enter your email and we'll send you instructions to reset your password.", + const SizedBox(height: 10), + MyText( + "Streamline Project Management\nBoost Productivity with Automation.", + fontSize: 14, color: Colors.black54, textAlign: TextAlign.center, ), - const SizedBox(height: 32), - TextFormField( - validator: controller.basicValidator - .getValidation('email'), - controller: controller.basicValidator - .getController('email'), - keyboardType: TextInputType.emailAddress, - style: MyTextStyle.labelMedium(), - decoration: InputDecoration( - labelText: "Email Address", - labelStyle: MyTextStyle.bodySmall(xMuted: true), - filled: true, - fillColor: Colors.grey.shade100, - prefixIcon: - const Icon(LucideIcons.mail, size: 20), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, + if (_isBetaEnvironment) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.orangeAccent, + borderRadius: BorderRadius.circular(6), + ), + child: MyText( + 'BETA', + color: Colors.white, + fontWeight: 600, + fontSize: 12, ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 16), - floatingLabelBehavior: - FloatingLabelBehavior.auto, ), - ), - const SizedBox(height: 40), - MyButton.rounded( - onPressed: - _isLoading ? null : _handleForgotPassword, - elevation: 2, - padding: MySpacing.xy(80, 16), - borderRadiusAll: 10, - backgroundColor: _isLoading ? Colors.red.withOpacity(0.6) : contentTheme.brandRed, - child: _isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ) - : MyText.labelLarge( - 'Send Reset Link', - fontWeight: 700, - color: Colors.white, - ), - ), - const SizedBox(height: 24), - TextButton( - onPressed: () async { - await LocalStorage.logout(); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.arrow_back, - size: 16, color: Colors.red), - const SizedBox(width: 4), - MyText.bodySmall( - 'Back to log in', - fontWeight: 600, - fontSize: 14, - color: contentTheme.brandRed, - ), - ], - ), - ), + ], + const SizedBox(height: 36), + _buildForgotCard(), ], ), ), ), ), - ), + ], ), - ], - ); - }), - ), - ); - } - - Widget _buildWelcomeTextsAndChips() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - children: [ - MyText.titleMedium( - "Welcome to Marco", - fontWeight: 600, - color: Colors.white, - textAlign: TextAlign.center, + ), ), - const SizedBox(height: 4), - MyText.bodySmall( - "Streamline Project Management and Boost Productivity with Automation.", - color: Colors.white70, - textAlign: TextAlign.center, - ), - if (_isBetaEnvironment) ...[ - const SizedBox(height: 8), - _buildBetaLabel(), - ], ], ), ); } - Widget _buildBetaLabel() { + Widget _buildForgotCard() { return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.orangeAccent, - borderRadius: BorderRadius.circular(4), - ), - child: MyText.bodySmall( - 'BETA', - fontWeight: 600, - color: Colors.white, - ), - ); - } - - Widget _buildHeader() { - return Container( - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(20), boxShadow: const [ BoxShadow( color: Colors.black12, - blurRadius: 6, - offset: Offset(0, 3), + blurRadius: 10, + offset: Offset(0, 4), ), ], ), - child: Image.asset(Images.logoDark, height: 70), + child: Form( + key: controller.basicValidator.formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + MyText( + 'Forgot Password', + fontSize: 20, + fontWeight: 700, + color: Colors.black87, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + MyText( + "Enter your email and we'll send you instructions to reset your password.", + fontSize: 14, + color: Colors.black54, + textAlign: TextAlign.center, + ), + const SizedBox(height: 30), + TextFormField( + validator: controller.basicValidator.getValidation('email'), + controller: controller.basicValidator.getController('email'), + keyboardType: TextInputType.emailAddress, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + labelText: "Email Address", + labelStyle: const TextStyle(color: Colors.black54), + filled: true, + fillColor: Colors.grey.shade100, + prefixIcon: const Icon(LucideIcons.mail, size: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + ), + const SizedBox(height: 32), + MyButton.rounded( + onPressed: _isLoading ? null : _handleForgotPassword, + elevation: 2, + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16), + borderRadiusAll: 10, + backgroundColor: _isLoading + ? Colors.red.withOpacity(0.6) + : contentTheme.brandRed, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: Colors.white, strokeWidth: 2), + ) + : MyText.bodyMedium( + 'Send Reset Link', + color: Colors.white, + fontWeight: 700, + fontSize: 16, + ), + ), + const SizedBox(height: 20), + TextButton.icon( + onPressed: () async => await LocalStorage.logout(), + icon: const Icon(Icons.arrow_back, + size: 18, color: Colors.redAccent), + label: MyText.bodyMedium( + 'Back to Login', + color: contentTheme.brandRed, + fontWeight: 600, + fontSize: 14, + ), + ), + ], + ), + ), ); } } + +// Same red wave background as MPINAuthScreen +class _RedWaveBackground extends StatelessWidget { + const _RedWaveBackground(); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WavePainter(), + size: Size.infinite, + ); + } +} + +class _WavePainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint1 = Paint() + ..shader = const LinearGradient( + colors: [Color(0xFFB71C1C), Color(0xFFD32F2F)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); + + final path1 = Path() + ..moveTo(0, size.height * 0.2) + ..quadraticBezierTo(size.width * 0.25, size.height * 0.05, + size.width * 0.5, size.height * 0.15) + ..quadraticBezierTo( + size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1) + ..lineTo(size.width, 0) + ..lineTo(0, 0) + ..close(); + + canvas.drawPath(path1, paint1); + + final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15); + final path2 = Path() + ..moveTo(0, size.height * 0.25) + ..quadraticBezierTo( + size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2) + ..lineTo(size.width, 0) + ..lineTo(0, 0) + ..close(); + + canvas.drawPath(path2, paint2); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} diff --git a/lib/view/auth/login_option_screen.dart b/lib/view/auth/login_option_screen.dart index c155cb9..572a402 100644 --- a/lib/view/auth/login_option_screen.dart +++ b/lib/view/auth/login_option_screen.dart @@ -1,225 +1,318 @@ import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/images.dart'; import 'package:marco/view/auth/email_login_form.dart'; import 'package:marco/view/auth/otp_login_form.dart'; import 'package:marco/helpers/services/api_endpoints.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; // Make sure this import is added +import 'package:marco/view/auth/request_demo_bottom_sheet.dart'; enum LoginOption { email, otp } -class LoginOptionScreen extends StatefulWidget { +class LoginOptionScreen extends StatelessWidget { const LoginOptionScreen({super.key}); @override - State createState() => _LoginOptionScreenState(); + Widget build(BuildContext context) => const WelcomeScreen(); } -class _LoginOptionScreenState extends State with UIMixin { - LoginOption _selectedOption = LoginOption.email; +class WelcomeScreen extends StatefulWidget { + const WelcomeScreen({super.key}); + + @override + State createState() => _WelcomeScreenState(); +} + +class _WelcomeScreenState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _logoAnimation; + bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: contentTheme.brandRed, - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - return Column( + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _logoAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeOutBack, + ); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _showLoginDialog(BuildContext context, LoginOption option) { + showDialog( + context: context, + barrierDismissible: false, // Prevent dismiss on outside tap + builder: (_) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + insetPadding: const EdgeInsets.all(24), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 24), - _buildHeader(), - const SizedBox(height: 16), - _buildWelcomeTextsAndChips(), - const SizedBox(height: 16), - Expanded( - child: Container( - width: double.infinity, - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: - BorderRadius.vertical(top: Radius.circular(32)), - ), - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 24), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 200, - ), - child: IntrinsicHeight( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildLoginForm(), - const SizedBox(height: 24), - const SizedBox(height: 8), - Center(child: _buildVersionInfo()), - ], - ), - ), + // Row with title and close button + Row( + children: [ + Expanded( + child: MyText( + option == LoginOption.email + ? "Login with Email" + : "Login with OTP", + fontSize: 20, + fontWeight: 700, ), ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 20), + option == LoginOption.email + ? EmailLoginForm() + : const OTPLoginScreen(), + ], + ), + ), + ), + ), + ), + ); +} + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + + return Scaffold( + body: Stack( + children: [ + const _RedWaveBackground(), + SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: screenWidth < 500 ? double.infinity : 420, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Logo with circular background + ScaleTransition( + scale: _logoAnimation, + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ), + padding: const EdgeInsets.all(20), + child: Image.asset(Images.logoDark), + ), + ), + + const SizedBox(height: 24), + + // Welcome Text + MyText( + "Welcome to Marco", + fontSize: 26, + fontWeight: 800, + color: Colors.black87, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + MyText( + "Streamline Project Management\nBoost Productivity with Automation.", + fontSize: 14, + color: Colors.black54, + textAlign: TextAlign.center, + ), + + if (_isBetaEnvironment) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.orangeAccent, + borderRadius: BorderRadius.circular(6), + ), + child: MyText( + 'BETA', + color: Colors.white, + fontWeight: 600, + fontSize: 12, + ), + ), + ], + + const SizedBox(height: 36), + + _buildActionButton( + context, + label: "Login with Username", + icon: LucideIcons.mail, + option: LoginOption.email, + ), + const SizedBox(height: 16), + _buildActionButton( + context, + label: "Login with OTP", + icon: LucideIcons.message_square, + option: LoginOption.otp, + ), + const SizedBox(height: 16), + _buildActionButton( + context, + label: "Request a Demo", + icon: LucideIcons.phone_call, + option: null, + ), + + const SizedBox(height: 36), + MyText( + 'App version 1.0.0', + color: Colors.grey, + fontSize: 12, + ), + ], ), ), - ], - ); - }, - ), - ), - ); - } - - Widget _buildHeader() { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black12, - blurRadius: 6, - offset: Offset(0, 3), - ) - ], - ), - child: Image.asset(Images.logoDark, height: 70), - ); - } - - Widget _buildBetaLabel() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.orangeAccent, - borderRadius: BorderRadius.circular(4), - ), - child: MyText( - 'BETA', - color: Colors.white, - fontWeight: 600, - fontSize: 12, - ), - ); - } - - Widget _buildLoginOptionChips() { - return Wrap( - spacing: 12, - runSpacing: 8, - alignment: WrapAlignment.center, - children: [ - _buildOptionChip( - title: "User Name", - icon: LucideIcons.mail, - value: LoginOption.email, - ), - _buildOptionChip( - title: "OTP", - icon: LucideIcons.message_square, - value: LoginOption.otp, - ), - ], - ); - } - - Widget _buildWelcomeTextsAndChips() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - children: [ - MyText( - "Welcome to Marco", - fontSize: 20, - fontWeight: 700, - color: Colors.white, - textAlign: TextAlign.center, + ), + ), ), - const SizedBox(height: 4), - MyText( - "Streamline Project Management and Boost Productivity with Automation.", - fontSize: 14, - color: Colors.white70, - textAlign: TextAlign.center, - ), - if (_isBetaEnvironment) ...[ - const SizedBox(height: 8), - _buildBetaLabel(), - ], - const SizedBox(height: 20), - _buildLoginOptionChips(), ], ), ); } - Widget _buildOptionChip({ - required String title, + Widget _buildActionButton( + BuildContext context, { + required String label, required IconData icon, - required LoginOption value, + LoginOption? option, }) { - final bool isSelected = _selectedOption == value; - - final Color selectedTextColor = contentTheme.brandRed; - final Color unselectedTextColor = Colors.white; - final Color selectedBgColor = Colors.grey[100]!; - final Color unselectedBgColor = contentTheme.brandRed; - - return ChoiceChip( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: 18, - color: isSelected ? selectedTextColor : unselectedTextColor, - ), - const SizedBox(width: 6), - MyText( - title, - fontSize: 14, + return SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + icon: Icon(icon, size: 20, color: Colors.white), + label: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: MyText( + label, + fontSize: 16, fontWeight: 600, - color: isSelected ? selectedTextColor : unselectedTextColor, + color: Colors.white, ), - ], - ), - selected: isSelected, - onSelected: (_) => setState(() => _selectedOption = value), - selectedColor: selectedBgColor, - backgroundColor: unselectedBgColor, - side: BorderSide( - color: Colors.white.withOpacity(0.6), - width: 1.2, - ), - elevation: 3, - shadowColor: Colors.black12, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15), - ), - ); - } - - Widget _buildLoginForm() { - switch (_selectedOption) { - case LoginOption.email: - return EmailLoginForm(); - case LoginOption.otp: - return const OTPLoginScreen(); - } - } - - Widget _buildVersionInfo() { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: MyText( - 'App version 1.0.0', - color: Colors.grey.shade500, - fontSize: 12, + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFB71C1C), // Red background + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 4, + shadowColor: Colors.black26, + ), + onPressed: () { + if (option == null) { + OrganizationFormBottomSheet.show(context); + } else { + _showLoginDialog(context, option); + } + }, ), ); } } + +/// Custom red wave background shifted lower to reduce red area at top +class _RedWaveBackground extends StatelessWidget { + const _RedWaveBackground(); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WavePainter(), + size: Size.infinite, + ); + } +} + +class _WavePainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint1 = Paint() + ..shader = const LinearGradient( + colors: [Color(0xFFB71C1C), Color(0xFFD32F2F)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); + + final path1 = Path() + ..moveTo(0, size.height * 0.2) + ..quadraticBezierTo( + size.width * 0.25, + size.height * 0.05, + size.width * 0.5, + size.height * 0.15, + ) + ..quadraticBezierTo( + size.width * 0.75, + size.height * 0.25, + size.width, + size.height * 0.1, + ) + ..lineTo(size.width, 0) + ..lineTo(0, 0) + ..close(); + + canvas.drawPath(path1, paint1); + + final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15); + + final path2 = Path() + ..moveTo(0, size.height * 0.25) + ..quadraticBezierTo( + size.width * 0.4, + size.height * 0.1, + size.width, + size.height * 0.2, + ) + ..lineTo(size.width, 0) + ..lineTo(0, 0) + ..close(); + + canvas.drawPath(path2, paint2); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} diff --git a/lib/view/auth/mpin_auth_screen.dart b/lib/view/auth/mpin_auth_screen.dart index 3efb225..adbfe6f 100644 --- a/lib/view/auth/mpin_auth_screen.dart +++ b/lib/view/auth/mpin_auth_screen.dart @@ -16,168 +16,171 @@ class MPINAuthScreen extends StatefulWidget { State createState() => _MPINAuthScreenState(); } -class _MPINAuthScreenState extends State with UIMixin { +class _MPINAuthScreenState extends State + with UIMixin, SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _logoAnimation; + bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _logoAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeOutBack, + ); + _controller.forward(); + } + @override void dispose() { Get.delete(); + _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final MPINController controller = Get.put(MPINController()); + final controller = Get.put(MPINController()); return Scaffold( - backgroundColor: contentTheme.brandRed, - body: SafeArea( - child: LayoutBuilder(builder: (context, constraints) { - return Column( - children: [ - _buildHeader(), - const SizedBox(height: 16), - _buildWelcomeTextsAndChips(), - const SizedBox(height: 16), - Expanded( - child: Container( - width: double.infinity, - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: - BorderRadius.vertical(top: Radius.circular(32)), + body: Stack( + children: [ + const _RedWaveBackground(), + SafeArea( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 24), + // Static Logo (not scrollable) + ScaleTransition( + scale: _logoAnimation, + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ), + padding: const EdgeInsets.all(20), + child: Image.asset(Images.logoDark), + ), ), - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: 24, vertical: 32), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 120), - child: Obx(() { - final isNewUser = controller.isNewUser.value; - - return IntrinsicHeight( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - MyText.headlineSmall( - isNewUser ? 'Generate MPIN' : 'Enter MPIN', - fontWeight: 700, - color: Colors.black87, - ), - const SizedBox(height: 8), - MyText.bodyMedium( - isNewUser - ? 'Set your 6-digit MPIN for quick login.' - : 'Enter your 6-digit MPIN to continue.', - color: Colors.black54, - fontSize: 16, - textAlign: TextAlign.center, - ), - const SizedBox(height: 32), - _buildMPINForm(controller, isNewUser), - const SizedBox(height: 40), - _buildSubmitButton(controller, isNewUser), - const SizedBox(height: 24), - _buildFooterOptions(controller, isNewUser), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 24), - child: Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - onPressed: () async { - await LocalStorage.logout(); - }, - icon: const Icon(Icons.arrow_back, - color: Colors.white), - label: MyText.bodyMedium( - 'Back to Home Page', - color: Colors.white, - fontWeight: 600, - fontSize: 14, - ), - style: TextButton.styleFrom( - foregroundColor: Colors.white, - ), - ), + const SizedBox(height: 8), + // Scrollable content below the logo + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + children: [ + const SizedBox(height: 12), + MyText( + "Welcome to Marco", + fontSize: 24, + fontWeight: 800, + color: Colors.black87, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + MyText( + "Streamline Project Management\nBoost Productivity with Automation.", + fontSize: 14, + color: Colors.black54, + textAlign: TextAlign.center, + ), + if (_isBetaEnvironment) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.orangeAccent, + borderRadius: BorderRadius.circular(6), + ), + child: MyText( + 'BETA', + color: Colors.white, + fontWeight: 600, + fontSize: 12, ), ), ], - ), - ); - }), + const SizedBox(height: 36), + _buildMPINCard(controller), + ], + ), + ), ), ), - ), + ], ), - ], - ); - }), + ), + ), + ], ), ); } - Widget _buildWelcomeTextsAndChips() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - children: [ - MyText.headlineSmall( - "Welcome to Marco", - fontWeight: 700, - color: Colors.white, - textAlign: TextAlign.center, - fontSize: 20, - ), - const SizedBox(height: 4), - MyText.bodyMedium( - "Streamline Project Management and Boost Productivity with Automation.", - color: Colors.white70, - fontSize: 14, - textAlign: TextAlign.center, - ), - if (_isBetaEnvironment) ...[ - const SizedBox(height: 8), - _buildBetaLabel(), + Widget _buildMPINCard(MPINController controller) { + return Obx(() { + final isNewUser = controller.isNewUser.value; + + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 10, + offset: Offset(0, 4), + ), ], - ], - ), - ); - } - - Widget _buildBetaLabel() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.orangeAccent, - borderRadius: BorderRadius.circular(4), - ), - child: MyText.bodySmall( - 'BETA', - color: Colors.white, - fontWeight: 600, - fontSize: 12, - ), - ); - } - - Widget _buildHeader() { - return Container( - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.symmetric(horizontal: 24), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 6, - offset: Offset(0, 3), - ), - ], - ), - child: Image.asset(Images.logoDark, height: 70), - ); + ), + child: Column( + children: [ + MyText( + isNewUser ? 'Generate MPIN' : 'Enter MPIN', + fontSize: 20, + fontWeight: 700, + color: Colors.black87, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + MyText( + isNewUser + ? 'Set your 6-digit MPIN for quick login.' + : 'Enter your 6-digit MPIN to continue.', + fontSize: 14, + color: Colors.black54, + textAlign: TextAlign.center, + ), + const SizedBox(height: 30), + _buildMPINForm(controller, isNewUser), + const SizedBox(height: 32), + _buildSubmitButton(controller, isNewUser), + const SizedBox(height: 20), + _buildFooterOptions(controller, isNewUser), + ], + ), + ); + }); } Widget _buildMPINForm(MPINController controller, bool isNewUser) { @@ -187,8 +190,8 @@ class _MPINAuthScreenState extends State with UIMixin { children: [ _buildDigitRow(controller, isRetype: false), if (isNewUser) ...[ - const SizedBox(height: 24), - MyText.bodyMedium( + const SizedBox(height: 20), + MyText( 'Retype MPIN', fontWeight: 600, color: Colors.black.withOpacity(0.6), @@ -203,8 +206,10 @@ class _MPINAuthScreenState extends State with UIMixin { } Widget _buildDigitRow(MPINController controller, {required bool isRetype}) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, + return Wrap( + alignment: WrapAlignment.center, + spacing: 0, + runSpacing: 12, children: List.generate(6, (index) { return _buildDigitBox(controller, index, isRetype); }), @@ -221,7 +226,7 @@ class _MPINAuthScreenState extends State with UIMixin { return Container( margin: const EdgeInsets.symmetric(horizontal: 6), - width: 40, + width: 30, height: 55, child: TextFormField( controller: textController, @@ -294,10 +299,7 @@ class _MPINAuthScreenState extends State with UIMixin { children: [ if (isNewUser) TextButton.icon( - onPressed: () async { - Get.delete(); - Get.toNamed('/dashboard'); - }, + onPressed: () => Get.toNamed('/dashboard'), icon: const Icon(Icons.arrow_back, size: 18, color: Colors.redAccent), label: MyText.bodyMedium( @@ -310,7 +312,6 @@ class _MPINAuthScreenState extends State with UIMixin { if (showBackToLogin) TextButton.icon( onPressed: () async { - Get.delete(); await LocalStorage.logout(); }, icon: const Icon(Icons.arrow_back, @@ -327,3 +328,53 @@ class _MPINAuthScreenState extends State with UIMixin { }); } } + +class _RedWaveBackground extends StatelessWidget { + const _RedWaveBackground(); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WavePainter(), + size: Size.infinite, + ); + } +} + +class _WavePainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint1 = Paint() + ..shader = const LinearGradient( + colors: [Color(0xFFB71C1C), Color(0xFFD32F2F)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); + + final path1 = Path() + ..moveTo(0, size.height * 0.2) + ..quadraticBezierTo(size.width * 0.25, size.height * 0.05, + size.width * 0.5, size.height * 0.15) + ..quadraticBezierTo( + size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1) + ..lineTo(size.width, 0) + ..lineTo(0, 0) + ..close(); + + canvas.drawPath(path1, paint1); + + final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15); + final path2 = Path() + ..moveTo(0, size.height * 0.25) + ..quadraticBezierTo( + size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2) + ..lineTo(size.width, 0) + ..lineTo(0, 0) + ..close(); + + canvas.drawPath(path2, paint2); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} diff --git a/lib/view/auth/request_demo_bottom_sheet.dart b/lib/view/auth/request_demo_bottom_sheet.dart index b3b21c4..6977089 100644 --- a/lib/view/auth/request_demo_bottom_sheet.dart +++ b/lib/view/auth/request_demo_bottom_sheet.dart @@ -187,28 +187,48 @@ class _OrganizationFormState extends State<_OrganizationForm> with UIMixin { ], ), const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: contentTheme.brandRed, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + OutlinedButton.icon( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.arrow_back, color: Colors.red), + label: MyText.bodyMedium("Back", color: Colors.red), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 14), ), ), - onPressed: _loading ? null : _submitForm, - child: _loading - ? const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ) - : MyText.labelLarge('Submit', color: Colors.white), - ), + ElevatedButton.icon( + onPressed: _loading ? null : _submitForm, + icon: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Icon(Icons.check_circle_outline, + color: Colors.white), + label: _loading + ? const SizedBox.shrink() + : MyText.bodyMedium("Submit", color: Colors.white), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + horizontal: 28, vertical: 14), + ), + ), + ], ), const SizedBox(height: 8), Center( diff --git a/lib/view/dashboard/Attendence/attendance_screen.dart b/lib/view/dashboard/Attendence/attendance_screen.dart index 3974484..12d8e34 100644 --- a/lib/view/dashboard/Attendence/attendance_screen.dart +++ b/lib/view/dashboard/Attendence/attendance_screen.dart @@ -71,48 +71,58 @@ class _AttendanceScreenState extends State with UIMixin { Widget build(BuildContext context) { return Scaffold( appBar: PreferredSize( - preferredSize: const Size.fromHeight(80), + preferredSize: const Size.fromHeight(72), child: AppBar( backgroundColor: const Color(0xFFF5F5F5), elevation: 0.5, - foregroundColor: Colors.black, + automaticallyImplyLeading: false, titleSpacing: 0, - centerTitle: false, - leading: Padding( - padding: const EdgeInsets.only(top: 15.0), - child: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () { - Get.offNamed('/dashboard'); - }, - ), - ), title: Padding( - padding: const EdgeInsets.only(top: 15.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - MyText.titleLarge( - 'Attendance', - fontWeight: 700, - color: Colors.black, + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), ), - const SizedBox(height: 2), - GetBuilder( - builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return MyText.bodySmall( - projectName, - fontWeight: 600, - maxLines: 1, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ); - }, + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Attendance', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + 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], + ), + ), + ], + ); + }, + ), + ], + ), ), ], ), diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 5aa671a..de064f4 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -25,6 +25,7 @@ class DashboardScreen extends StatefulWidget { static const String dailyTasksRoute = "/dashboard/daily-task-planing"; static const String dailyTasksProgressRoute = "/dashboard/daily-task-progress"; + static const String directoryMainPageRoute = "/dashboard/directory-main-page"; @override State createState() => _DashboardScreenState(); @@ -154,6 +155,8 @@ class _DashboardScreenState extends State with UIMixin { DashboardScreen.dailyTasksRoute), _StatItem(LucideIcons.list_todo, "Daily Task Progress", contentTheme.info, DashboardScreen.dailyTasksProgressRoute), + _StatItem(LucideIcons.folder, "Directory", contentTheme.info, + DashboardScreen.directoryMainPageRoute), ]; return GetBuilder( diff --git a/lib/view/directory/contact_detail_screen.dart b/lib/view/directory/contact_detail_screen.dart new file mode 100644 index 0000000..f6e5962 --- /dev/null +++ b/lib/view/directory/contact_detail_screen.dart @@ -0,0 +1,548 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_html/flutter_html.dart' as html; +import 'package:flutter_quill/flutter_quill.dart' as quill; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/directory/directory_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/directory/contact_model.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/helpers/utils/launcher_utils.dart'; +import 'package:tab_indicator_styler/tab_indicator_styler.dart'; +import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart'; +import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart'; +import 'package:marco/model/directory/add_comment_bottom_sheet.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/model/directory/add_contact_bottom_sheet.dart'; + +class ContactDetailScreen extends StatefulWidget { + final ContactModel contact; + + const ContactDetailScreen({super.key, required this.contact}); + + @override + State createState() => _ContactDetailScreenState(); +} + +String _convertDeltaToHtml(dynamic delta) { + final buffer = StringBuffer(); + bool inList = false; + + for (var op in delta.toList()) { + final data = op.data?.toString() ?? ''; + final attr = op.attributes ?? {}; + + final isListItem = attr.containsKey('list'); + + // Start list + if (isListItem && !inList) { + buffer.write('
      '); + inList = true; + } + + // Close list if we are not in list mode anymore + if (!isListItem && inList) { + buffer.write('
    '); + inList = false; + } + + if (isListItem) buffer.write('
  • '); + + // Apply inline styles + if (attr.containsKey('bold')) buffer.write(''); + if (attr.containsKey('italic')) buffer.write(''); + if (attr.containsKey('underline')) buffer.write(''); + if (attr.containsKey('strike')) buffer.write(''); + if (attr.containsKey('link')) buffer.write(''); + + buffer.write(data.replaceAll('\n', '')); + + if (attr.containsKey('link')) buffer.write(''); + if (attr.containsKey('strike')) buffer.write(''); + if (attr.containsKey('underline')) buffer.write(''); + if (attr.containsKey('italic')) buffer.write(''); + if (attr.containsKey('bold')) buffer.write(''); + + if (isListItem) + buffer.write('
  • '); + else if (data.contains('\n')) buffer.write('
    '); + } + + if (inList) buffer.write(''); + + return buffer.toString(); +} + +class _ContactDetailScreenState extends State { + late final DirectoryController directoryController; + late final ProjectController projectController; + + @override + void initState() { + super.initState(); + directoryController = Get.find(); + projectController = Get.find(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!directoryController.contactCommentsMap + .containsKey(widget.contact.id)) { + directoryController.fetchCommentsForContact(widget.contact.id); + } + }); + } + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + child: Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: _buildMainAppBar(), + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSubHeader(), + Expanded( + child: TabBarView( + children: [ + _buildDetailsTab(), + _buildCommentsTab(context), + ], + ), + ), + ], + ), + ), + ), + ); + } + + PreferredSizeWidget _buildMainAppBar() { + return AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.2, + 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.back(), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge('Contact Profile', + fontWeight: 700, color: Colors.black), + MySpacing.height(2), + GetBuilder( + 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], + ), + ), + ], + ); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildSubHeader() { + return Padding( + padding: MySpacing.xy(16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Avatar( + firstName: widget.contact.name.split(" ").first, + lastName: widget.contact.name.split(" ").length > 1 + ? widget.contact.name.split(" ").last + : "", + size: 35, + backgroundColor: Colors.indigo, + ), + MySpacing.width(12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall(widget.contact.name, + fontWeight: 600, color: Colors.black), + MySpacing.height(2), + MyText.bodySmall(widget.contact.organization, + fontWeight: 500, color: Colors.grey[700]), + ], + ), + ], + ), + TabBar( + labelColor: Colors.red, + unselectedLabelColor: Colors.black, + indicator: MaterialIndicator( + color: Colors.red, + height: 4, + topLeftRadius: 8, + topRightRadius: 8, + bottomLeftRadius: 8, + bottomRightRadius: 8, + ), + tabs: const [ + Tab(text: "Details"), + Tab(text: "Comments"), + ], + ), + ], + ), + ); + } + + Widget _buildDetailsTab() { + final email = widget.contact.contactEmails.isNotEmpty + ? widget.contact.contactEmails.first.emailAddress + : "-"; + + final phone = widget.contact.contactPhones.isNotEmpty + ? widget.contact.contactPhones.first.phoneNumber + : "-"; + + final tags = widget.contact.tags.map((e) => e.name).join(", "); + + final bucketNames = widget.contact.bucketIds + .map((id) => directoryController.contactBuckets + .firstWhereOrNull((b) => b.id == id) + ?.name) + .whereType() + .join(", "); + + final projectNames = widget.contact.projectIds + ?.map((id) => projectController.projects + .firstWhereOrNull((p) => p.id == id) + ?.name) + .whereType() + .join(", ") ?? + "-"; + + final category = widget.contact.contactCategory?.name ?? "-"; + + return Stack( + children: [ + SingleChildScrollView( + padding: MySpacing.fromLTRB(8, 8, 8, 80), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(12), + _infoCard("Basic Info", [ + _iconInfoRow(Icons.email, "Email", email, + onTap: () => LauncherUtils.launchEmail(email), + onLongPress: () => LauncherUtils.copyToClipboard(email, + typeLabel: "Email")), + _iconInfoRow(Icons.phone, "Phone", phone, + onTap: () => LauncherUtils.launchPhone(phone), + onLongPress: () => LauncherUtils.copyToClipboard(phone, + typeLabel: "Phone")), + _iconInfoRow( + Icons.location_on, "Address", widget.contact.address), + ]), + _infoCard("Organization", [ + _iconInfoRow(Icons.business, "Organization", + widget.contact.organization), + _iconInfoRow(Icons.category, "Category", category), + ]), + _infoCard("Meta Info", [ + _iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"), + _iconInfoRow(Icons.folder_shared, "Contact Buckets", + bucketNames.isNotEmpty ? bucketNames : "-"), + _iconInfoRow(Icons.work_outline, "Projects", projectNames), + ]), + _infoCard("Description", [ + MySpacing.height(6), + Align( + alignment: Alignment.topLeft, + child: MyText.bodyMedium( + widget.contact.description, + color: Colors.grey[800], + maxLines: 10, + textAlign: TextAlign.left, + ), + ), + ]) + ], + ), + ), + Positioned( + bottom: 20, + right: 20, + child: FloatingActionButton.extended( + backgroundColor: Colors.red, + onPressed: () { + Get.bottomSheet( + AddContactBottomSheet(existingContact: widget.contact), + isScrollControlled: true, + backgroundColor: Colors.transparent, + ); + }, + icon: const Icon(Icons.edit, color: Colors.white), + label: const Text( + "Edit Contact", + style: TextStyle(color: Colors.white), + ), + ), + ), + ], + ); + } + + Widget _buildCommentsTab(BuildContext context) { + return Obx(() { + final contactId = widget.contact.id; + + if (!directoryController.contactCommentsMap.containsKey(contactId)) { + return const Center(child: CircularProgressIndicator()); + } + + final comments = directoryController + .getCommentsForContact(contactId) + .reversed + .toList(); + + final editingId = directoryController.editingCommentId.value; + + return Stack( + children: [ + comments.isEmpty + ? Center( + child: + MyText.bodyLarge("No comments yet.", color: Colors.grey), + ) + : Padding( + padding: MySpacing.xy(12, 12), + child: ListView.separated( + padding: const EdgeInsets.only(bottom: 100), + itemCount: comments.length, + separatorBuilder: (_, __) => MySpacing.height(14), + itemBuilder: (_, index) { + final comment = comments[index]; + final isEditing = editingId == comment.id; + + final initials = comment.createdBy.firstName.isNotEmpty + ? comment.createdBy.firstName[0].toUpperCase() + : "?"; + + final decodedDelta = HtmlToDelta().convert(comment.note); + + final quillController = isEditing + ? quill.QuillController( + document: quill.Document.fromDelta(decodedDelta), + selection: TextSelection.collapsed( + offset: decodedDelta.length), + ) + : null; + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: MySpacing.xy(8, 7), + decoration: BoxDecoration( + color: isEditing ? Colors.indigo[50] : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isEditing + ? Colors.indigo + : Colors.grey.shade300, + width: 1.2, + ), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: Offset(0, 2), + ) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar( + firstName: initials, + lastName: '', + size: 36), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + MyText.bodyMedium( + "By: ${comment.createdBy.firstName}", + fontWeight: 600, + color: Colors.indigo[800], + ), + MySpacing.height(4), + MyText.bodySmall( + DateTimeUtils.convertUtcToLocal( + comment.createdAt.toString(), + format: 'dd MMM yyyy, hh:mm a', + ), + color: Colors.grey[600], + ), + ], + ), + ), + IconButton( + icon: Icon( + isEditing ? Icons.close : Icons.edit, + size: 20, + color: Colors.indigo, + ), + onPressed: () { + directoryController.editingCommentId.value = + isEditing ? null : comment.id; + }, + ), + ], + ), + // Comment Content + if (isEditing && quillController != null) + CommentEditorCard( + controller: quillController, + onCancel: () { + directoryController.editingCommentId.value = + null; + }, + onSave: (controller) async { + final delta = controller.document.toDelta(); + final htmlOutput = _convertDeltaToHtml(delta); + final updated = + comment.copyWith(note: htmlOutput); + await directoryController + .updateComment(updated); + directoryController.editingCommentId.value = + null; + }, + ) + else + html.Html( + data: comment.note, + style: { + "body": html.Style( + margin: html.Margins.zero, + padding: html.HtmlPaddings.zero, + fontSize: html.FontSize.medium, + color: Colors.black87, + ), + }, + ), + ], + ), + ); + }, + ), + ), + + // Floating Action Button + if (directoryController.editingCommentId.value == null) + Positioned( + bottom: 20, + right: 20, + child: FloatingActionButton.extended( + backgroundColor: Colors.red, + onPressed: () { + Get.bottomSheet( + AddCommentBottomSheet(contactId: contactId), + isScrollControlled: true, + ); + }, + icon: const Icon(Icons.add_comment, color: Colors.white), + label: const Text( + "Add Comment", + style: TextStyle(color: Colors.white), + ), + ), + ), + ], + ); + }); + } + + Widget _iconInfoRow(IconData icon, String label, String value, + {VoidCallback? onTap, VoidCallback? onLongPress}) { + return Padding( + padding: MySpacing.y(8), + child: GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 22, color: Colors.indigo), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall(label, + fontWeight: 600, color: Colors.black87), + MySpacing.height(2), + MyText.bodyMedium(value, color: Colors.grey[800]), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _infoCard(String title, List children) { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 2, + margin: MySpacing.bottom(12), + child: Padding( + padding: MySpacing.xy(16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall(title, + fontWeight: 700, color: Colors.indigo[700]), + MySpacing.height(8), + ...children, + ], + ), + ), + ); + } +} diff --git a/lib/view/directory/directory_main_screen.dart b/lib/view/directory/directory_main_screen.dart new file mode 100644 index 0000000..b8b1fdc --- /dev/null +++ b/lib/view/directory/directory_main_screen.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'package:marco/controller/directory/directory_controller.dart'; +import 'package:marco/controller/directory/notes_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/view/directory/directory_view.dart'; +import 'package:marco/view/directory/notes_view.dart'; + +class DirectoryMainScreen extends StatelessWidget { + DirectoryMainScreen({super.key}); + + final DirectoryController controller = Get.put(DirectoryController()); + final NotesController notesController = Get.put(NotesController()); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + 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( + 'Directory', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + 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: Column( + children: [ + // Toggle between Directory and Notes + Padding( + padding: MySpacing.fromLTRB(8, 12, 8, 5), + child: Obx(() { + final isNotesView = controller.isNotesView.value; + + return Container( + padding: EdgeInsets.all(2), + decoration: BoxDecoration( + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => controller.isNotesView.value = false, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric( + vertical: 6, horizontal: 10), + decoration: BoxDecoration( + color: !isNotesView + ? Colors.red + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.contacts, + size: 16, + color: !isNotesView + ? Colors.white + : Colors.grey), + const SizedBox(width: 6), + Text( + 'Directory', + style: TextStyle( + color: !isNotesView + ? Colors.white + : Colors.grey, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ), + ), + ), + ), + Expanded( + child: GestureDetector( + onTap: () => controller.isNotesView.value = true, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric( + vertical: 6, horizontal: 10), + decoration: BoxDecoration( + color: + isNotesView ? Colors.red : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.notes, + size: 16, + color: isNotesView + ? Colors.white + : Colors.grey), + const SizedBox(width: 6), + Text( + 'Notes', + style: TextStyle( + color: isNotesView + ? Colors.white + : Colors.grey, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + }), + ), + + // Main View + Expanded( + child: Obx(() => + controller.isNotesView.value ? NotesView() : DirectoryView()), + ), + ], + ), + ), + ); + } +} diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart new file mode 100644 index 0000000..135da9f --- /dev/null +++ b/lib/view/directory/directory_view.dart @@ -0,0 +1,370 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +import 'package:marco/controller/directory/directory_controller.dart'; +import 'package:marco/helpers/utils/launcher_utils.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/model/directory/add_contact_bottom_sheet.dart'; +import 'package:marco/model/directory/directory_filter_bottom_sheet.dart'; +import 'package:marco/view/directory/contact_detail_screen.dart'; + +class DirectoryView extends StatelessWidget { + final DirectoryController controller = Get.find(); + final TextEditingController searchController = TextEditingController(); + + Future _refreshDirectory() async { + try { + await controller.fetchContacts(); + } catch (e, stackTrace) { + debugPrint('Error refreshing directory data: ${e.toString()}'); + debugPrintStack(stackTrace: stackTrace); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + floatingActionButton: FloatingActionButton( + backgroundColor: Colors.red, + onPressed: () async { + final result = await Get.bottomSheet( + AddContactBottomSheet(), + isScrollControlled: true, + backgroundColor: Colors.transparent, + ); + if (result == true) { + controller.fetchContacts(); + } + }, + child: const Icon(Icons.add, color: Colors.white), + ), + body: Column( + children: [ + Padding( + padding: MySpacing.xy(8, 8), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: searchController, + onChanged: (value) { + controller.searchQuery.value = value; + controller.applyFilters(); + }, + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: const Icon(Icons.search, + size: 20, color: Colors.grey), + hintText: 'Search contacts...', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ), + ), + ), + MySpacing.width(8), + Tooltip( + message: 'Refresh Data', + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: _refreshDirectory, + child: const Padding( + padding: EdgeInsets.all(0), + child: Icon(Icons.refresh, color: Colors.green, size: 28), + ), + ), + ), + MySpacing.width(8), + Obx(() { + final isFilterActive = controller.hasActiveFilters(); + return Stack( + children: [ + Container( + height: 35, + width: 35, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(10), + ), + child: IconButton( + icon: Icon(Icons.filter_alt_outlined, + size: 20, + color: isFilterActive + ? Colors.indigo + : Colors.black87), + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(20)), + ), + builder: (_) => + const DirectoryFilterBottomSheet(), + ); + }, + ), + ), + if (isFilterActive) + Positioned( + top: 6, + right: 6, + child: Container( + height: 8, + width: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + }), + MySpacing.width(10), + Container( + height: 35, + width: 35, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(10), + ), + child: PopupMenuButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.more_vert, + size: 20, color: Colors.black87), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + itemBuilder: (context) => [ + PopupMenuItem( + value: 0, + enabled: false, + child: Obx(() => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodySmall('Show Inactive', + fontWeight: 600), + Switch.adaptive( + value: !controller.isActive.value, + activeColor: Colors.indigo, + onChanged: (val) { + controller.isActive.value = !val; + controller.fetchContacts(active: !val); + Navigator.pop(context); + }, + ), + ], + )), + ), + ], + ), + ), + ], + ), + ), + Expanded( + child: Obx(() { + if (controller.isLoading.value) { + return ListView.separated( + itemCount: 10, + separatorBuilder: (_, __) => MySpacing.height(12), + itemBuilder: (_, __) => SkeletonLoaders.contactSkeletonCard(), + ); + } + + if (controller.filteredContacts.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.contact_page_outlined, + size: 60, color: Colors.grey), + const SizedBox(height: 12), + MyText.bodyMedium('No contacts found.', fontWeight: 500), + ], + ), + ); + } + + return ListView.separated( + padding: MySpacing.only(left: 8, right: 8, top: 4, bottom: 80), + itemCount: controller.filteredContacts.length, + separatorBuilder: (_, __) => MySpacing.height(12), + itemBuilder: (_, index) { + final contact = controller.filteredContacts[index]; + final nameParts = contact.name.trim().split(" "); + final firstName = nameParts.first; + final lastName = nameParts.length > 1 ? nameParts.last : ""; + final tags = contact.tags.map((tag) => tag.name).toList(); + + return InkWell( + onTap: () { + Get.to(() => ContactDetailScreen(contact: contact)); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar( + firstName: firstName, + lastName: lastName, + size: 35), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall(contact.name, + fontWeight: 600, + overflow: TextOverflow.ellipsis), + MyText.bodySmall(contact.organization, + color: Colors.grey[700], + overflow: TextOverflow.ellipsis), + MySpacing.height(8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...contact.contactEmails.map((e) => + GestureDetector( + onTap: () => + LauncherUtils.launchEmail( + e.emailAddress), + onLongPress: () => + LauncherUtils.copyToClipboard( + e.emailAddress, + typeLabel: 'Email'), + child: Padding( + padding: const EdgeInsets.only( + bottom: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.email_outlined, + size: 16, + color: Colors.indigo), + MySpacing.width(4), + ConstrainedBox( + constraints: + const BoxConstraints( + maxWidth: 180), + child: MyText.labelSmall( + e.emailAddress, + overflow: + TextOverflow.ellipsis, + color: Colors.indigo, + decoration: TextDecoration + .underline, + ), + ), + ], + ), + ), + )), + ...contact.contactPhones.map((p) => Padding( + padding: + const EdgeInsets.only(bottom: 8,top: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => + LauncherUtils.launchPhone( + p.phoneNumber), + onLongPress: () => LauncherUtils + .copyToClipboard( + p.phoneNumber, + typeLabel: + 'Phone number'), + child: Row( + mainAxisSize: + MainAxisSize.min, + children: [ + const Icon( + Icons.phone_outlined, + size: 16, + color: Colors.indigo), + MySpacing.width(4), + ConstrainedBox( + constraints: + const BoxConstraints( + maxWidth: 140), + child: MyText.labelSmall( + p.phoneNumber, + overflow: TextOverflow + .ellipsis, + color: Colors.indigo, + decoration: + TextDecoration + .underline, + ), + ), + ], + ), + ), + MySpacing.width(8), + GestureDetector( + onTap: () => LauncherUtils + .launchWhatsApp( + p.phoneNumber), + child: const FaIcon( + FontAwesomeIcons.whatsapp, + color: Colors.green, + size: 16), + ), + ], + ), + )) + ], + ), + if (tags.isNotEmpty) ...[ + MySpacing.height(2), + MyText.labelSmall(tags.join(', '), + color: Colors.grey[500], + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + ], + ), + ), + Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const Icon(Icons.arrow_forward_ios, + color: Colors.grey, size: 16), + MySpacing.height(8), + ], + ), + ], + ), + ), + ); + }, + ); + }), + ), + ], + ), + ); + } +} diff --git a/lib/view/directory/notes_view.dart b/lib/view/directory/notes_view.dart new file mode 100644 index 0000000..c4f56c3 --- /dev/null +++ b/lib/view/directory/notes_view.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_quill/flutter_quill.dart' as quill; +import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart'; +import 'package:flutter_html/flutter_html.dart' as html; + +import 'package:marco/controller/directory/notes_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart'; + +class NotesView extends StatelessWidget { + final NotesController controller = Get.find(); + final TextEditingController searchController = TextEditingController(); + + NotesView({super.key}); + + Future _refreshNotes() async { + try { + await controller.fetchNotes(); + } catch (e, st) { + debugPrint('Error refreshing notes: $e'); + debugPrintStack(stackTrace: st); + } + } + + String _convertDeltaToHtml(dynamic delta) { + final buffer = StringBuffer(); + bool inList = false; + + for (var op in delta.toList()) { + final data = op.data?.toString() ?? ''; + final attr = op.attributes ?? {}; + final isListItem = attr.containsKey('list'); + + if (isListItem && !inList) { + buffer.write('
      '); + inList = true; + } + if (!isListItem && inList) { + buffer.write('
    '); + inList = false; + } + + if (isListItem) buffer.write('
  • '); + + if (attr.containsKey('bold')) buffer.write(''); + if (attr.containsKey('italic')) buffer.write(''); + if (attr.containsKey('underline')) buffer.write(''); + if (attr.containsKey('strike')) buffer.write(''); + if (attr.containsKey('link')) buffer.write(''); + + buffer.write(data.replaceAll('\n', '')); + + if (attr.containsKey('link')) buffer.write(''); + if (attr.containsKey('strike')) buffer.write(''); + if (attr.containsKey('underline')) buffer.write(''); + if (attr.containsKey('italic')) buffer.write(''); + if (attr.containsKey('bold')) buffer.write(''); + + if (isListItem) + buffer.write('
  • '); + else if (data.contains('\n')) buffer.write('
    '); + } + + if (inList) buffer.write(''); + + return buffer.toString(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + /// 🔍 Search + Refresh (Top Row) + Padding( + padding: MySpacing.xy(8, 8), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: searchController, + onChanged: (value) => controller.searchQuery.value = value, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 6), + prefixIcon: const Icon(Icons.search, + size: 20, color: Colors.grey), + hintText: 'Search notes...', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ), + ), + ), + MySpacing.width(8), + Tooltip( + message: 'Refresh Notes', + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: _refreshNotes, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon(Icons.refresh, color: Colors.green, size: 26), + ), + ), + ), + ), + ], + ), + ), + + /// 📄 Notes List View + Expanded( + child: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + final notes = controller.filteredNotesList; + + if (notes.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.note_alt_outlined, + size: 60, color: Colors.grey), + const SizedBox(height: 12), + MyText.bodyMedium('No notes found.', fontWeight: 500), + ], + ), + ); + } + + return ListView.separated( + padding: MySpacing.only(left: 8, right: 8, top: 4, bottom: 80), + itemCount: notes.length, + separatorBuilder: (_, __) => MySpacing.height(12), + itemBuilder: (_, index) { + final note = notes[index]; + + return Obx(() { + final isEditing = controller.editingNoteId.value == note.id; + + final initials = note.contactName.trim().isNotEmpty + ? note.contactName + .trim() + .split(' ') + .map((e) => e[0]) + .take(2) + .join() + .toUpperCase() + : "NA"; + + final createdDate = DateTimeUtils.convertUtcToLocal( + note.createdAt.toString(), + format: 'dd MMM yyyy'); + final createdTime = DateTimeUtils.convertUtcToLocal( + note.createdAt.toString(), + format: 'hh:mm a'); + + final decodedDelta = HtmlToDelta().convert(note.note); + final quillController = isEditing + ? quill.QuillController( + document: quill.Document.fromDelta(decodedDelta), + selection: TextSelection.collapsed( + offset: decodedDelta.length), + ) + : null; + + return AnimatedContainer( + duration: const Duration(milliseconds: 250), + padding: MySpacing.xy(12, 12), + decoration: BoxDecoration( + color: isEditing ? Colors.indigo[50] : Colors.white, + border: Border.all( + color: isEditing ? Colors.indigo : Colors.grey.shade300, + width: 1.1, + ), + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: Offset(0, 2)), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + /// Header Row + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar(firstName: initials, lastName: '', size: 40), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall( + "${note.contactName} (${note.organizationName})", + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.indigo[800], + ), + MyText.bodySmall( + "by ${note.createdBy.firstName} • $createdDate, $createdTime", + color: Colors.grey[600], + ), + ], + ), + ), + IconButton( + icon: Icon( + isEditing ? Icons.close : Icons.edit, + color: Colors.indigo, + size: 20, + ), + onPressed: () { + controller.editingNoteId.value = + isEditing ? null : note.id; + }, + ), + ], + ), + + MySpacing.height(12), + + /// Content + if (isEditing && quillController != null) + CommentEditorCard( + controller: quillController, + onCancel: () => + controller.editingNoteId.value = null, + onSave: (quillCtrl) async { + final delta = quillCtrl.document.toDelta(); + final htmlOutput = _convertDeltaToHtml(delta); + final updated = note.copyWith(note: htmlOutput); + await controller.updateNote(updated); + controller.editingNoteId.value = null; + }, + ) + else + html.Html( + data: note.note, + style: { + "body": html.Style( + margin: html.Margins.zero, + padding: html.HtmlPaddings.zero, + fontSize: html.FontSize.medium, + color: Colors.black87, + ), + }, + ), + ], + ), + ); + }); + }, + ); + }), + ), + ], + ); + } +} diff --git a/lib/view/employees/employees_screen.dart b/lib/view/employees/employees_screen.dart index 37bbc28..3e3dbef 100644 --- a/lib/view/employees/employees_screen.dart +++ b/lib/view/employees/employees_screen.dart @@ -15,6 +15,7 @@ import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/model/employees/employee_detail_bottom_sheet.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; + class EmployeesScreen extends StatefulWidget { const EmployeesScreen({super.key}); @@ -73,49 +74,59 @@ class _EmployeesScreenState extends State with UIMixin { Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF5F5F5), - appBar: PreferredSize( - preferredSize: const Size.fromHeight(80), + appBar: PreferredSize( + preferredSize: const Size.fromHeight(72), child: AppBar( backgroundColor: const Color(0xFFF5F5F5), elevation: 0.5, - foregroundColor: Colors.black, + automaticallyImplyLeading: false, titleSpacing: 0, - centerTitle: false, - leading: Padding( - padding: const EdgeInsets.only(top: 15.0), // Aligns with title - child: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () { - Get.offNamed('/dashboard'); - }, - ), - ), title: Padding( - padding: const EdgeInsets.only(top: 15.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - MyText.titleLarge( - 'Employees', - fontWeight: 700, - color: Colors.black, + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), ), - const SizedBox(height: 2), - GetBuilder( - builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return MyText.bodySmall( - projectName, - fontWeight: 600, - maxLines: 1, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ); - }, + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Employees', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + 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], + ), + ), + ], + ); + }, + ), + ], + ), ), ], ), diff --git a/lib/view/layouts/user_profile_right_bar.dart b/lib/view/layouts/user_profile_right_bar.dart index 265a102..8b37bbf 100644 --- a/lib/view/layouts/user_profile_right_bar.dart +++ b/lib/view/layouts/user_profile_right_bar.dart @@ -59,14 +59,20 @@ class _UserProfileBarState extends State width: isCondensed ? 90 : 250, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, - child: Column( - children: [ - userProfileSection(), - MySpacing.height(8), - supportAndSettingsMenu(), - const Spacer(), - logoutButton(), - ], + child: SafeArea( + bottom: true, + top: false, + left: false, + right: false, + child: Column( + children: [ + userProfileSection(), + MySpacing.height(8), + supportAndSettingsMenu(), + const Spacer(), + logoutButton(), + ], + ), ), ), ); diff --git a/lib/view/my_app.dart b/lib/view/my_app.dart index 605a78c..a27ae57 100644 --- a/lib/view/my_app.dart +++ b/lib/view/my_app.dart @@ -25,7 +25,7 @@ class MyApp extends StatelessWidget { } final bool hasMpin = LocalStorage.getIsMpin(); - logSafe("MPIN enabled: $hasMpin", sensitive: true); + logSafe("MPIN enabled: $hasMpin", ); if (hasMpin) { await LocalStorage.setBool("mpin_verified", false); diff --git a/lib/view/taskPlaning/daily_progress.dart b/lib/view/taskPlaning/daily_progress.dart index 8a7e8a4..3aea78d 100644 --- a/lib/view/taskPlaning/daily_progress.dart +++ b/lib/view/taskPlaning/daily_progress.dart @@ -67,48 +67,58 @@ class _DailyProgressReportScreenState extends State Widget build(BuildContext context) { return Scaffold( appBar: PreferredSize( - preferredSize: const Size.fromHeight(80), + preferredSize: const Size.fromHeight(72), child: AppBar( backgroundColor: const Color(0xFFF5F5F5), elevation: 0.5, - foregroundColor: Colors.black, + automaticallyImplyLeading: false, titleSpacing: 0, - centerTitle: false, - leading: Padding( - padding: const EdgeInsets.only(top: 15.0), // Aligns with title - child: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () { - Get.offNamed('/dashboard'); - }, - ), - ), title: Padding( - padding: const EdgeInsets.only(top: 15.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - MyText.titleLarge( - 'Daily Task Progress', - fontWeight: 700, - color: Colors.black, + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), ), - const SizedBox(height: 2), - GetBuilder( - builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return MyText.bodySmall( - projectName, - fontWeight: 600, - maxLines: 1, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ); - }, + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Daily Task Progress', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + 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], + ), + ), + ], + ); + }, + ), + ], + ), ), ], ), @@ -451,41 +461,44 @@ class _DailyProgressReportScreenState extends State : Colors.red[700], ), const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (task.reportedDate == null || - task.reportedDate - .toString() - .isEmpty) ...[ - TaskActionButtons.reportButton( - context: context, - task: task, - completed: completed.toInt(), - refreshCallback: _refreshData, - ), - const SizedBox(width: 8), - ] else if (task.approvedBy == null) ...[ - TaskActionButtons.reportActionButton( + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (task.reportedDate == null || + task.reportedDate + .toString() + .isEmpty) ...[ + TaskActionButtons.reportButton( + context: context, + task: task, + completed: completed.toInt(), + refreshCallback: _refreshData, + ), + const SizedBox(width: 4), + ] else if (task.approvedBy == null) ...[ + 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(), - completed: completed.toInt(), refreshCallback: _refreshData, ), - const SizedBox(width: 8), ], - TaskActionButtons.commentButton( - context: context, - task: task, - parentTaskID: parentTaskID, - workAreaId: workAreaId.toString(), - activityId: activityId.toString(), - refreshCallback: _refreshData, - ), - ], + ), ) ], ), diff --git a/lib/view/taskPlaning/daily_task_planing.dart b/lib/view/taskPlaning/daily_task_planing.dart index 0e96a60..d734b14 100644 --- a/lib/view/taskPlaning/daily_task_planing.dart +++ b/lib/view/taskPlaning/daily_task_planing.dart @@ -53,48 +53,58 @@ class _DailyTaskPlaningScreenState extends State Widget build(BuildContext context) { return Scaffold( appBar: PreferredSize( - preferredSize: const Size.fromHeight(80), + preferredSize: const Size.fromHeight(72), child: AppBar( backgroundColor: const Color(0xFFF5F5F5), elevation: 0.5, - foregroundColor: Colors.black, + automaticallyImplyLeading: false, titleSpacing: 0, - centerTitle: false, - leading: Padding( - padding: const EdgeInsets.only(top: 15.0), // Aligns with title - child: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () { - Get.offNamed('/dashboard'); - }, - ), - ), title: Padding( - padding: const EdgeInsets.only(top: 15.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - MyText.titleLarge( - 'Daily Task Planning', - fontWeight: 700, - color: Colors.black, + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), ), - const SizedBox(height: 2), - GetBuilder( - builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return MyText.bodySmall( - projectName, - fontWeight: 600, - maxLines: 1, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ); - }, + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Daily Task Planing', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + 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], + ), + ), + ], + ); + }, + ), + ], + ), ), ], ), diff --git a/pubspec.yaml b/pubspec.yaml index 371d19d..fef9493 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,7 +52,7 @@ dependencies: intl: ^0.19.0 syncfusion_flutter_core: ^28.1.33 syncfusion_flutter_sliders: ^28.1.33 - file_picker: ^8.1.5 + file_picker: ^9.2.3 timelines_plus: ^1.0.4 syncfusion_flutter_charts: ^28.1.33 appflowy_board: ^0.1.2 @@ -71,6 +71,12 @@ dependencies: flutter_contacts: ^1.1.9+2 photo_view: ^0.15.0 jwt_decoder: ^2.0.1 + font_awesome_flutter: ^10.8.0 + flutter_html: ^3.0.0 + tab_indicator_styler: ^2.0.0 + html_editor_enhanced: ^2.7.0 + flutter_quill_delta_from_html: ^1.5.2 + quill_delta: ^3.0.0-nullsafety.2 dev_dependencies: flutter_test: sdk: flutter