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/directory/add_contact_controller.dart b/lib/controller/directory/add_contact_controller.dart new file mode 100644 index 0000000..1c2bace --- /dev/null +++ b/lib/controller/directory/add_contact_controller.dart @@ -0,0 +1,254 @@ +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; + + @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(), + ]); + } + + 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({ + required String name, + required String organization, + required String email, + required String emailLabel, + required String phone, + required String phoneLabel, + required String address, + required String description, + }) async { + try { + final categoryId = categoriesMap[selectedCategory.value]; + final bucketId = bucketsMap[selectedBucket.value]; + final projectId = projectsMap[selectedProject.value]; + + final tagObjects = enteredTags.map((tagName) { + final tagId = tagsMap[tagName]; + return tagId != null + ? {"id": tagId, "name": tagName} + : {"name": tagName}; + }).toList(); + + final body = { + "name": name, + "organization": organization, + "contactCategoryId": categoryId, + "projectIds": projectId != null ? [projectId] : [], + "bucketIds": bucketId != null ? [bucketId] : [], + "tags": tagObjects, + "contactEmails": [ + { + "label": emailLabel, + "emailAddress": email, + } + ], + "contactPhones": [ + { + "label": phoneLabel, + "phoneNumber": phone, + } + ], + "address": address, + "description": description, + }; + + logSafe("Submitting contact", sensitive: true); + + final response = await ApiService.createContact(body); + if (response == true) { + logSafe("Contact creation succeeded"); + + // Send result back to previous screen + Get.back(result: true); + + showAppSnackbar( + title: "Success", + message: "Contact created successfully", + type: SnackbarType.success, + ); + } else { + logSafe("Contact creation failed", level: LogLevel.error); + showAppSnackbar( + title: "Error", + message: "Failed to create contact", + type: SnackbarType.error, + ); + } + } catch (e) { + logSafe("Contact creation 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..75f0974 --- /dev/null +++ b/lib/controller/directory/directory_controller.dart @@ -0,0 +1,165 @@ +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'; + +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; + RxMap> contactCommentsMap = + >{}.obs; + + @override + void onInit() { + super.onInit(); + fetchContacts(); + fetchBuckets(); + } + + 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(); + } + + Future fetchCommentsForContact(String contactId) async { + try { + final data = await ApiService.getDirectoryComments(contactId); + logSafe("Fetched comments for contact $contactId: $data"); + + if (data != null ) { + final comments = data.map((e) => DirectoryComment.fromJson(e)).toList(); + contactCommentsMap[contactId] = comments; + } else { + contactCommentsMap[contactId] = []; + } + + contactCommentsMap.refresh(); + } catch (e) { + logSafe("Error fetching comments for contact $contactId: $e", + level: LogLevel.error); + contactCommentsMap[contactId] = []; + contactCommentsMap.refresh(); + } +} + + + 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 applyFilters() { + final query = searchQuery.value.toLowerCase(); + + filteredContacts.value = allContacts.where((contact) { + // 1. Category filter + final categoryMatch = selectedCategories.isEmpty || + (contact.contactCategory != null && + selectedCategories.contains(contact.contactCategory!.id)); + + // 2. Bucket filter + final bucketMatch = selectedBuckets.isEmpty || + contact.bucketIds.any((id) => selectedBuckets.contains(id)); + + // 3. Search filter: match name, organization, email, or 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 tagMatch = + contact.tags.any((tag) => tag.name.toLowerCase().contains(query)); + + final searchMatch = + query.isEmpty || nameMatch || orgMatch || emailMatch || tagMatch; + + 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/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 12809fd..18c70fe 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -31,4 +31,14 @@ class ApiEndpoints { 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 getDirectoryNotes = "/directory/notes"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 805446b..cd63e6c 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -177,6 +177,82 @@ class ApiService { : null); } + /// Directly calling the API +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 createContact(Map payload) async { + try { + logSafe("Submitting contact payload: $payload", sensitive: true); + + 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 response = await _getRequest(ApiEndpoints.getDirectoryOrganization); + 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) { + logSafe("Failed to fetch organization names: $e", level: LogLevel.error); + } + 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 +395,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..d674a39 100644 --- a/lib/helpers/services/app_initializer.dart +++ b/lib/helpers/services/app_initializer.dart @@ -10,39 +10,39 @@ import 'package:marco/helpers/services/app_logger.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."); await ThemeCustomizer.init(); - logSafe("Theme customizer initialized."); + logSafe("💡 Theme customizer initialized."); Get.put(PermissionController()); - logSafe("PermissionController injected."); + logSafe("💡 PermissionController injected."); Get.put(ProjectController(), permanent: true); - logSafe("ProjectController injected as permanent."); + logSafe("💡 ProjectController injected as permanent."); 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/utils/launcher_utils.dart b/lib/helpers/utils/launcher_utils.dart new file mode 100644 index 0000000..daaf082 --- /dev/null +++ b/lib/helpers/utils/launcher_utils.dart @@ -0,0 +1,98 @@ +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 { + static Future launchPhone(String phoneNumber) async { + logSafe('Attempting to launch phone: $phoneNumber', sensitive: true); + + final Uri url = Uri(scheme: 'tel', path: phoneNumber); + await _tryLaunch(url, 'Could not launch phone'); + } + + static Future launchEmail(String email) async { + logSafe('Attempting to launch email: $email', sensitive: true); + + final Uri url = Uri(scheme: 'mailto', path: email); + await _tryLaunch(url, 'Could not launch email'); + } + + static Future launchWhatsApp(String phoneNumber) async { + logSafe('Attempting to launch WhatsApp with: $phoneNumber', + sensitive: true); + + String normalized = phoneNumber.replaceAll(RegExp(r'\D'), ''); + if (!normalized.startsWith('91')) { + normalized = '91$normalized'; + } + + logSafe('Normalized WhatsApp number: $normalized', sensitive: true); + + if (normalized.length < 12) { + logSafe('Invalid WhatsApp number: $normalized', sensitive: true); + 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'); + } + + 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, sensitive: true); + + showAppSnackbar( + title: 'Error', + message: 'Failed to copy $typeLabel', + type: SnackbarType.error, + ); + } + } + + static Future _tryLaunch(Uri url, String errorMsg) async { + try { + logSafe('Trying to launch URL: ${url.toString()}'); + + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + logSafe('URL launched successfully: ${url.toString()}'); + } else { + logSafe( + 'Launch failed - canLaunchUrl 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/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_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/dailyTaskPlaning/comment_task_bottom_sheet.dart b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart index a646f7b..a7f8ee1 100644 --- a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart @@ -518,28 +518,31 @@ class _CommentTaskBottomSheetState extends State ); } - Widget buildCommentActionButtons({ - required VoidCallback onCancel, - required Future Function() onSubmit, - required RxBool isLoading, - double? buttonHeight, - }) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton.icon( + Widget buildCommentActionButtons({ + required VoidCallback onCancel, + required Future Function() onSubmit, + required RxBool isLoading, + double? buttonHeight, +}) { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( onPressed: onCancel, - icon: const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", color: Colors.red), + 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: 20, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), ), ), - Obx(() { + ), + const SizedBox(width: 16), + Expanded( + child: Obx(() { return ElevatedButton.icon( onPressed: isLoading.value ? null : () => onSubmit(), icon: isLoading.value @@ -551,22 +554,23 @@ class _CommentTaskBottomSheetState extends State valueColor: AlwaysStoppedAnimation(Colors.white), ), ) - : const Icon(Icons.check_circle_outline, color: Colors.white), + : const Icon(Icons.check_circle_outline, color: Colors.white, size: 18), label: isLoading.value ? const SizedBox() - : MyText.bodyMedium("Comment", color: Colors.white), + : 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: 28, vertical: 14), + 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 4013aea..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) { @@ -502,7 +501,7 @@ class _ReportActionBottomSheetState extends State isLoading: controller.isLoading, ), - MySpacing.height(10), + MySpacing.height(20), if ((widget.taskData['taskComments'] as List?) ?.isNotEmpty == true) ...[ @@ -678,28 +677,31 @@ class _ReportActionBottomSheetState extends State ); } - Widget buildCommentActionButtons({ - required VoidCallback onCancel, - required Future Function() onSubmit, - required RxBool isLoading, - double? buttonHeight, - }) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton.icon( + Widget buildCommentActionButtons({ + required VoidCallback onCancel, + required Future Function() onSubmit, + required RxBool isLoading, + double? buttonHeight, +}) { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( onPressed: onCancel, - icon: const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", color: Colors.red), + 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: 20, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), ), ), - Obx(() { + ), + const SizedBox(width: 16), + Expanded( + child: Obx(() { return ElevatedButton.icon( onPressed: isLoading.value ? null : () => onSubmit(), icon: isLoading.value @@ -711,22 +713,23 @@ class _ReportActionBottomSheetState extends State valueColor: AlwaysStoppedAnimation(Colors.white), ), ) - : const Icon(Icons.send), + : const Icon(Icons.send, color: Colors.white, size: 18), label: isLoading.value ? const SizedBox() - : MyText.bodyMedium("Submit", color: Colors.white), + : 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: 28, vertical: 14), + 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 694751b..7991f4e 100644 --- a/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart @@ -345,89 +345,92 @@ class _ReportTaskBottomSheetState extends State ], ); }), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton.icon( - onPressed: () => Navigator.of(context).pop(), - 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(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 14), - ), - ), - Obx(() { - final isLoading = controller.reportStatus.value == - ApiStatus.loading; + 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), + ), + ); + }), + ), + ], +), - 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: 28, vertical: 14), - ), - ); - }), - ], - ), ], ), ), 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..5c26fd9 --- /dev/null +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -0,0 +1,444 @@ +// unchanged imports +import 'package:flutter/material.dart'; +import 'package:get/get.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'; + +class AddContactBottomSheet extends StatelessWidget { + AddContactBottomSheet({super.key}); + + final controller = Get.put(AddContactController()); + final formKey = GlobalKey(); + + final emailLabel = 'Office'.obs; + final phoneLabel = 'Work'.obs; + + final nameController = TextEditingController(); + final emailController = TextEditingController(); + final phoneController = TextEditingController(); + final orgController = TextEditingController(); + final tagTextController = TextEditingController(); + final addressController = TextEditingController(); + final descriptionController = TextEditingController(); + + 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 _popupSelector({ + required String hint, + required RxString selectedValue, + required List options, + }) { + return Obx(() => GestureDetector( + onTap: () async { + final selected = await showMenu( + context: Navigator.of(Get.context!).overlay!.context, + position: const RelativeRect.fromLTRB(100, 300, 100, 0), + items: options + .map((e) => PopupMenuItem(value: e, child: Text(e))) + .toList(), + ); + if (selected != null) selectedValue.value = selected; + }, + child: AbsorbPointer( + child: SizedBox( + height: 48, + child: TextFormField( + readOnly: true, + initialValue: selectedValue.value, + style: const TextStyle(fontSize: 14), + decoration: _inputDecoration(hint) + .copyWith(suffixIcon: const Icon(Icons.expand_more)), + ), + ), + ), + )); + } + + Widget _dropdownField({ + required String label, + required RxString selectedValue, + required RxList options, + }) { + return Obx(() => SizedBox( + height: 48, + child: PopupMenuButton( + onSelected: (value) => selectedValue.value = value, + itemBuilder: (_) => options + .map((item) => PopupMenuItem(value: item, child: Text(item))) + .toList(), + padding: EdgeInsets.zero, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade100, + ), + alignment: Alignment.centerLeft, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + selectedValue.value.isEmpty ? label : selectedValue.value, + style: const TextStyle(fontSize: 14), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ), + )); + } + + 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() + : _buildSuggestionsList()), + MySpacing.height(8), + Obx(() => Wrap( + spacing: 8, + children: controller.enteredTags + .map((tag) => Chip( + label: Text(tag), + onDeleted: () => controller.removeEnteredTag(tag), + )) + .toList(), + )), + ], + ); + } + + Widget _buildSuggestionsList() => 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, offset: Offset(0, 2)), + ], + ), + 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(); + }, + ); + }, + ), + ); + + Widget _sectionLabel(String title) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelLarge(title, fontWeight: 600), + MySpacing.height(4), + Divider(thickness: 1, color: Colors.grey.shade200), + ], + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SingleChildScrollView( + padding: MediaQuery.of(context).viewInsets, + 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)) + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + 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("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), + _buildLabeledRow( + "Email Label", + emailLabel, + ["Office", "Personal", "Other"], + "Email", + emailController, + TextInputType.emailAddress), + MySpacing.height(16), + _buildLabeledRow( + "Phone Label", + phoneLabel, + ["Work", "Mobile", "Other"], + "Phone", + phoneController, + TextInputType.phone), + MySpacing.height(24), + _sectionLabel("Other Details"), + MySpacing.height(16), + MyText.labelMedium("Category"), + MySpacing.height(8), + _dropdownField( + label: "Select Category", + selectedValue: controller.selectedCategory, + options: controller.categories, + ), + MySpacing.height(16), + MyText.labelMedium("Select Projects"), + MySpacing.height(8), + _dropdownField( + label: "Select Project", + selectedValue: controller.selectedProject, + options: controller.globalProjects, + ), + MySpacing.height(16), + MyText.labelMedium("Tags"), + MySpacing.height(8), + _tagInputSection(), + MySpacing.height(16), + MyText.labelMedium("Select Bucket"), + MySpacing.height(8), + _dropdownField( + label: "Select Bucket", + selectedValue: controller.selectedBucket, + options: controller.buckets, + ), + MySpacing.height(16), + _buildTextField("Address", addressController, maxLines: 2), + MySpacing.height(16), + _buildTextField("Description", descriptionController, + maxLines: 2), + MySpacing.height(24), + _buildActionButtons(), + ], + ), + ), + ), + ), + ); + } + + 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 _buildLabeledRow( + String label, + RxString selectedLabel, + List options, + String inputLabel, + TextEditingController controller, + TextInputType inputType, + ) { + return Row( + 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), + SizedBox( + height: 48, + child: TextFormField( + controller: controller, + keyboardType: inputType, + decoration: _inputDecoration("Enter $inputLabel"), + validator: (value) => + (value == null || value.trim().isEmpty) + ? "$inputLabel is required" + : null, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildActionButtons() { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => Get.back(), + 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()) { + controller.submitContact( + name: nameController.text.trim(), + organization: orgController.text.trim(), + email: emailController.text.trim(), + emailLabel: emailLabel.value, + phone: phoneController.text.trim(), + phoneLabel: phoneLabel.value, + 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), + ), + ), + ), + ], + ); + } +} 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..af6741f --- /dev/null +++ b/lib/model/directory/directory_comment_model.dart @@ -0,0 +1,101 @@ +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, + ); + } +} + +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'] ?? '', + ); + } +} 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..2095ecf --- /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: 8, + runSpacing: 8, + 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: 24), + ], + + /// Buckets + if (controller.contactBuckets.isNotEmpty) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodyMedium("Buckets", fontWeight: 600), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + 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: 30), + + /// 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: 20, vertical: 14), + ), + ), + 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: 28, vertical: 14), + ), + ), + ], + ), + const SizedBox(height: 10), + ], + ), + ); + }), + ); + } +} 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/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/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..7ffa1eb --- /dev/null +++ b/lib/view/directory/contact_detail_screen.dart @@ -0,0 +1,376 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +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:flutter_html/flutter_html.dart'; +class ContactDetailScreen extends StatelessWidget { + final ContactModel contact; + const ContactDetailScreen({super.key, required this.contact}); + @override + Widget build(BuildContext context) { + final directoryController = Get.find(); + final projectController = Get.find(); + Future.microtask(() { + if (!directoryController.contactCommentsMap.containsKey(contact.id)) { + directoryController.fetchCommentsForContact(contact.id); + } + }); + final email = contact.contactEmails.isNotEmpty + ? contact.contactEmails.first.emailAddress + : "-"; + final phone = contact.contactPhones.isNotEmpty + ? contact.contactPhones.first.phoneNumber + : "-"; + final createdDate = DateTime.now(); + final formattedDate = DateFormat('MMMM dd, yyyy').format(createdDate); + final tags = contact.tags.map((e) => e.name).join(", "); + final bucketNames = contact.bucketIds + .map((id) => directoryController.contactBuckets + .firstWhereOrNull((b) => b.id == id) + ?.name) + .whereType() + .join(", "); + final projectNames = contact.projectIds + ?.map((id) => projectController.projects + .firstWhereOrNull((p) => p.id == id) + ?.name) + .whereType() + .join(", ") ?? + "-"; + final category = contact.contactCategory?.name ?? "-"; + + return DefaultTabController( + length: 2, + child: Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: PreferredSize( + preferredSize: const Size.fromHeight(170), + child: AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + flexibleSpace: SafeArea( + child: Padding( + padding: MySpacing.xy(10, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Back button and title + Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.back(), + ), + const SizedBox(width: 4), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleLarge('Contact Profile', + fontWeight: 700, color: Colors.black), + const SizedBox(height: 2), + GetBuilder( + builder: (_) { + final projectName = + projectController.selectedProject?.name ?? + 'Select Project'; + return MyText.bodySmall( + projectName, + fontWeight: 600, + color: Colors.grey[700], + ); + }, + ), + ], + ), + ], + ), + const SizedBox(height: 12), + // Avatar + name + org + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Avatar( + firstName: contact.name.split(" ").first, + lastName: contact.name.split(" ").length > 1 + ? contact.name.split(" ").last + : "", + size: 35, + backgroundColor: Colors.indigo, + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall( + contact.name, + fontWeight: 600, + color: Colors.black, + ), + const SizedBox(height: 2), + MyText.titleSmall( + contact.organization, + fontWeight: 500, + color: Colors.grey[700], + ), + ], + ), + ], + ), + + const SizedBox(height: 6), + + // Tab Bar + const TabBar( + indicatorColor: Colors.indigo, + labelColor: Colors.indigo, + unselectedLabelColor: Colors.grey, + tabs: [ + Tab(text: "Details"), + Tab(text: "Comments"), + ], + ), + ], + ), + ), + ), + ), + ), + body: TabBarView( + children: [ + // Details Tab + SingleChildScrollView( + padding: MySpacing.xy(9, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _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.calendar_today, "Created", formattedDate), + _iconInfoRow(Icons.location_on, "Address", contact.address), + ]), + _infoCard("Organization", [ + _iconInfoRow( + Icons.business, "Organization", contact.organization), + _iconInfoRow(Icons.category, "Category", category), + ]), + _infoCard("Meta Info", [ + _iconInfoRow( + Icons.label, "Tags", tags.isNotEmpty ? tags : "-"), + _iconInfoRow(Icons.folder_shared, "Contat Buckets", + bucketNames.isNotEmpty ? bucketNames : "-"), + _iconInfoRow(Icons.work_outline, "Projects", projectNames), + ]), + _infoCard("Description", [ + const SizedBox(height: 6), + SizedBox( + width: double.infinity, + child: MyText.bodyMedium( + contact.description, + color: Colors.grey[800], + maxLines: 10, + ), + ), + ]), + ], + ), + ), + + // Comments Tab + // Improved Comments Tab + Obx(() { + final comments = + directoryController.contactCommentsMap[contact.id]; + + if (comments == null) { + return const Center(child: CircularProgressIndicator()); + } + + if (comments.isEmpty) { + return Center( + child: + MyText.bodyLarge("No comments yet.", color: Colors.grey), + ); + } + + return ListView.separated( + padding: MySpacing.xy(12, 16), + itemCount: comments.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (_, index) { + final comment = comments[index]; + final initials = comment.createdBy.firstName.isNotEmpty + ? comment.createdBy.firstName[0].toUpperCase() + : "?"; + + return Container( + padding: MySpacing.xy(14, 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: Offset(0, 2), + ) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar + By + Date Row at top + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Avatar( + firstName: initials, + lastName: '', + size: 31, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall( + "By: ${comment.createdBy.firstName}", + fontWeight: 600, + color: Colors.indigo[700], + ), + const SizedBox(height: 2), + MyText.bodySmall( + DateFormat('dd MMM yyyy, hh:mm a') + .format(comment.createdAt), + fontWeight: 500, + color: Colors.grey[600], + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.more_vert, + size: 20, color: Colors.grey), + onPressed: () {}, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + + const SizedBox(height: 10), + + // Comment content + Html( + data: comment.note, + style: { + "body": Style( + margin: Margins.all(0), + padding: HtmlPaddings.all(0), + fontSize: FontSize.medium, + color: Colors.black87, + ), + "pre": Style( + padding: HtmlPaddings.all(8), + fontSize: FontSize.small, + fontFamily: 'monospace', + backgroundColor: const Color(0xFFF1F1F1), + border: Border.all(color: Colors.grey.shade300), + ), + "h3": Style( + fontSize: FontSize.large, + fontWeight: FontWeight.bold, + color: Colors.indigo[700], + ), + "strong": Style( + fontWeight: FontWeight.w700, + ), + "p": Style( + margin: Margins.only(bottom: 8), + ), + }, + ), + ], + ), + ); + }, + ); + }) + ], + ), + ), + ); + } + + Widget _iconInfoRow(IconData icon, String label, String value, + {VoidCallback? onTap, VoidCallback? onLongPress}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 22, color: Colors.indigo), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall(label, + fontWeight: 600, color: Colors.black87), + const SizedBox(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: const EdgeInsets.only(bottom: 10), + child: Padding( + padding: MySpacing.xy(16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall(title, + fontWeight: 700, color: Colors.indigo[700]), + const SizedBox(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..3ab2d8e --- /dev/null +++ b/lib/view/directory/directory_main_screen.dart @@ -0,0 +1,524 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/directory/directory_controller.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/widgets/my_card.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/utils/my_shadow.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/model/directory/directory_filter_bottom_sheet.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:marco/helpers/utils/launcher_utils.dart'; +import 'package:marco/view/directory/contact_detail_screen.dart'; +import 'package:marco/model/directory/add_contact_bottom_sheet.dart'; + +class DirectoryMainScreen extends StatelessWidget { + DirectoryMainScreen({super.key}); + + final DirectoryController controller = Get.put(DirectoryController()); + 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: const Color(0xFFF5F5F5), + appBar: PreferredSize( + preferredSize: const Size.fromHeight(80), + child: AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + foregroundColor: Colors.black, + 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, + children: [ + MyText.titleLarge( + 'Directory', + fontWeight: 700, + color: Colors.black, + ), + 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], + ); + }, + ), + ], + ), + ), + ), + ), + floatingActionButton: FloatingActionButton( + backgroundColor: Colors.indigo, + 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: SafeArea( + child: Column( + children: [ + // Search + Filter + Toggle + Padding( + padding: MySpacing.xy(8, 10), + child: Row( + children: [ + // Compact Search Field + Expanded( + child: SizedBox( + height: 42, + 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: MouseRegion( + cursor: SystemMouseCursors.click, + child: const Padding( + padding: EdgeInsets.all(0), + child: Icon( + Icons.refresh, + color: Colors.green, + size: 28, + ), + ), + ), + ), + ), + MySpacing.width(8), + // Filter Icon with optional red dot + Obx(() { + final isFilterActive = controller.hasActiveFilters(); + return Stack( + children: [ + Container( + height: 38, + width: 38, + 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), + + // 3-dot Popup Menu with Toggle + Container( + height: 38, + width: 38, + 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); + }, + ), + ], + )), + ), + ], + ), + ), + ], + ), + ), + + // Contacts List + 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 phone = contact.contactPhones.isNotEmpty + ? contact.contactPhones.first.phoneNumber + : '-'; + final email = contact.contactEmails.isNotEmpty + ? contact.contactEmails.first.emailAddress + : '-'; + 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 MyCard.bordered( + margin: MySpacing.only(bottom: 2), + paddingAll: 8, + borderRadiusAll: 8, + shadow: MyShadow( + elevation: 1.5, + position: MyShadowPosition.bottom, + ), + onTap: () { + Get.to(() => ContactDetailScreen(contact: contact)); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Avatar( + firstName: firstName, + lastName: lastName, + size: 31, + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall( + contact.name, + fontWeight: 700, + color: Colors.black87, + ), + MyText.bodySmall( + contact.organization, + fontWeight: 500, + ), + ], + ), + ), + GestureDetector( + onTap: () { + Get.to(() => + ContactDetailScreen(contact: contact)); + }, + child: const Icon(Icons.arrow_forward_ios, + color: Colors.black, size: 15), + ), + MySpacing.width(4), + ], + ), + const Divider(), + if (contact.contactEmails.isNotEmpty || + contact.contactPhones.isNotEmpty) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Email Row + if (contact.contactEmails.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Row( + children: [ + GestureDetector( + onTap: () => + LauncherUtils.launchEmail(email), + child: const Padding( + padding: + EdgeInsets.only(right: 8.0), + child: Icon(Icons.email_outlined, + color: Colors.blue, size: 20), + ), + ), + Expanded( + child: GestureDetector( + onTap: () => + LauncherUtils.launchEmail( + email), + onLongPress: () => + LauncherUtils.copyToClipboard( + email, + typeLabel: 'Email'), + child: MyText.bodySmall( + email, + maxLines: 1, + overflow: TextOverflow.ellipsis, + color: Colors.blue, + fontWeight: 600, + textAlign: TextAlign.start, + decoration: + TextDecoration.underline, + ), + ), + ), + ], + ), + ), + + // Phone Row with icons at the end + if (contact.contactPhones.isNotEmpty) + Row( + children: [ + // Phone Icon + Padding( + padding: + const EdgeInsets.only(right: 6.0), + child: GestureDetector( + onTap: () => + LauncherUtils.launchPhone(phone), + child: const Icon( + Icons.phone_outlined, + color: Colors.blue, + size: 20), + ), + ), + + // Phone number text + Expanded( + child: GestureDetector( + onTap: () => + LauncherUtils.launchPhone(phone), + onLongPress: () => + LauncherUtils.copyToClipboard( + phone, + typeLabel: 'Phone number'), + child: MyText.bodySmall( + phone, + maxLines: 1, + overflow: TextOverflow.ellipsis, + color: Colors.blue, + fontWeight: 600, + textAlign: TextAlign.start, + decoration: + TextDecoration.underline, + ), + ), + ), + + // WhatsApp Icon + Padding( + padding: + const EdgeInsets.only(right: 6.0), + child: GestureDetector( + onTap: () => + LauncherUtils.launchWhatsApp( + phone), + child: const FaIcon( + FontAwesomeIcons.whatsapp, + color: Colors.green, + size: 18), + ), + ), + ], + ), + ], + ), + + MySpacing.height(8), + // Tags Section + if (tags.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + reverse: + true, // ensures scroll starts from right + child: Row( + children: tags.map((name) { + return Container( + margin: + const EdgeInsets.only(left: 6), + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + padding: + const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + backgroundColor: + const Color.fromARGB( + 255, 179, 207, 246), + tapTargetSize: + MaterialTapTargetSize + .shrinkWrap, + minimumSize: Size.zero, + visualDensity: + VisualDensity.standard, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(5), + ), + ), + child: Text( + name, + style: const TextStyle( + fontSize: 10, + color: Color.fromARGB( + 255, 0, 0, 0), + height: 1.2, + ), + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + }), + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 371d19d..5ed79e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,6 +71,8 @@ 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 dev_dependencies: flutter_test: sdk: flutter