diff --git a/lib/controller/directory/directory_controller.dart b/lib/controller/directory/directory_controller.dart index 72adfbd..f078227 100644 --- a/lib/controller/directory/directory_controller.dart +++ b/lib/controller/directory/directory_controller.dart @@ -18,6 +18,7 @@ class DirectoryController extends GetxController { 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) { diff --git a/lib/controller/directory/notes_controller.dart b/lib/controller/directory/notes_controller.dart new file mode 100644 index 0000000..462d81a --- /dev/null +++ b/lib/controller/directory/notes_controller.dart @@ -0,0 +1,103 @@ +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(); + + @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}"); + 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(); + } + } 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/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 3361e9e..e9fcc82 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -243,6 +243,27 @@ class ApiService { } /// 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, 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/view/directory/contact_detail_screen.dart b/lib/view/directory/contact_detail_screen.dart index a507237..a8e9b4d 100644 --- a/lib/view/directory/contact_detail_screen.dart +++ b/lib/view/directory/contact_detail_screen.dart @@ -319,13 +319,11 @@ class _ContactDetailScreenState extends State { MyText.bodyLarge("No comments yet.", color: Colors.grey), ) : Padding( - padding: MySpacing.xy(8, 8), // Same padding as Details tab + padding: MySpacing.xy(12, 12), child: ListView.separated( - padding: EdgeInsets.only( - bottom: 80, // Extra bottom padding to avoid FAB overlap - ), + padding: const EdgeInsets.only(bottom: 100), itemCount: comments.length, - separatorBuilder: (_, __) => MySpacing.height(12), + separatorBuilder: (_, __) => MySpacing.height(14), itemBuilder: (_, index) { final comment = comments[index]; final isEditing = editingId == comment.id; @@ -344,11 +342,18 @@ class _ContactDetailScreenState extends State { ) : null; - return Container( - padding: MySpacing.xy(14, 12), + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: MySpacing.xy(8, 7), decoration: BoxDecoration( - color: Colors.white, + 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, @@ -360,30 +365,31 @@ class _ContactDetailScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Header Row Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Avatar( firstName: initials, lastName: '', - size: 31), - MySpacing.width(8), + size: 36), + MySpacing.width(12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.bodySmall( - "By: ${comment.createdBy.firstName}", - fontWeight: 600, - color: Colors.indigo[700]), - MySpacing.height(2), + MyText.bodyMedium( + "By: ${comment.createdBy.firstName}", + fontWeight: 600, + color: Colors.indigo[800], + ), + MySpacing.height(4), MyText.bodySmall( DateTimeUtils.convertUtcToLocal( - comment.createdAt - .toString(), // pass as String + comment.createdAt.toString(), format: 'dd MMM yyyy, hh:mm a', ), - fontWeight: 500, color: Colors.grey[600], ), ], @@ -391,9 +397,10 @@ class _ContactDetailScreenState extends State { ), IconButton( icon: Icon( - isEditing ? Icons.close : Icons.edit, - size: 20, - color: Colors.grey[700]), + isEditing ? Icons.close : Icons.edit, + size: 20, + color: Colors.indigo, + ), onPressed: () { directoryController.editingCommentId.value = isEditing ? null : comment.id; @@ -401,32 +408,33 @@ class _ContactDetailScreenState extends State { ), ], ), - MySpacing.height(10), + MySpacing.height(12), + // 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; - }) + 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.all(0), - padding: html.HtmlPaddings.all(0), + margin: html.Margins.zero, + padding: html.HtmlPaddings.zero, fontSize: html.FontSize.medium, color: Colors.black87, ), @@ -439,13 +447,13 @@ class _ContactDetailScreenState extends State { ), ), - // Floating Action Button to Add Comment + // Floating Action Button if (directoryController.editingCommentId.value == null) Positioned( - bottom: 16, - right: 16, + bottom: 20, + right: 20, child: FloatingActionButton.extended( - backgroundColor: Colors.red, + backgroundColor: Colors.indigo, onPressed: () { Get.bottomSheet( AddCommentBottomSheet(contactId: contactId), @@ -453,8 +461,10 @@ class _ContactDetailScreenState extends State { ); }, icon: const Icon(Icons.add_comment, color: Colors.white), - label: const Text("Add Comment", - style: TextStyle(color: Colors.white)), + label: const Text( + "Add Comment", + style: TextStyle(color: Colors.white), + ), ), ), ], diff --git a/lib/view/directory/directory_main_screen.dart b/lib/view/directory/directory_main_screen.dart index 453c1f4..527c6cc 100644 --- a/lib/view/directory/directory_main_screen.dart +++ b/lib/view/directory/directory_main_screen.dart @@ -1,30 +1,18 @@ 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_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/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'; + +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 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) { @@ -61,9 +49,9 @@ class DirectoryMainScreen extends StatelessWidget { MySpacing.height(2), GetBuilder( builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; + final projectName = projectController + .selectedProject?.name ?? + 'Select Project'; return Row( children: [ const Icon(Icons.work_outline, @@ -89,377 +77,44 @@ class DirectoryMainScreen extends StatelessWidget { ), ), ), - 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: SafeArea( child: Column( children: [ - // Search + Filter + Toggle + // Toggle between Directory and Notes 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( + padding: MySpacing.xy(16, 10), 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 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.symmetric( - horizontal: 12.0, vertical: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Leading Icon - Avatar( - firstName: firstName, - lastName: lastName, - size: 45, - ), - MySpacing.width(12), - - // Middle Content - 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(6), - - // Launcher Row - 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) => - GestureDetector( - onTap: () => - LauncherUtils.launchPhone( - p.phoneNumber), - onLongPress: () => - LauncherUtils.copyToClipboard( - p.phoneNumber, - typeLabel: 'Phone number'), - child: Padding( - padding: const EdgeInsets.only( - bottom: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.phone_outlined, - size: 16, - color: Colors.indigo), - MySpacing.width(4), - ConstrainedBox( - constraints: - const BoxConstraints( - maxWidth: 160), - child: MyText.labelSmall( - p.phoneNumber, - overflow: - TextOverflow.ellipsis, - color: Colors.indigo, - decoration: TextDecoration - .underline, - ), - ), - ], - ), - ), - )), - ], - ), - - if (tags.isNotEmpty) ...[ - MySpacing.height(4), - MyText.labelSmall( - tags.join(', '), - color: Colors.grey[500], - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - // WhatsApp launcher icon - Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const Icon(Icons.arrow_forward_ios, - color: Colors.grey, size: 16), - MySpacing.height(12), - if (phone != '-') - GestureDetector( - onTap: () => - LauncherUtils.launchWhatsApp(phone), - child: const FaIcon( - FontAwesomeIcons.whatsapp, - color: Colors.green, - size: 20, - ), - ), - ], - ), - ], - ), + final isNotesView = controller.isNotesView.value; + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + tooltip: 'Directory View', + onPressed: () => controller.isNotesView.value = false, + icon: Icon( + Icons.contacts, + color: !isNotesView ? Colors.indigo : Colors.grey, ), - ); - }, + ), + IconButton( + tooltip: 'Notes View', + onPressed: () => controller.isNotesView.value = true, + icon: Icon( + Icons.notes, + color: isNotesView ? Colors.indigo : Colors.grey, + ), + ), + ], ); }), ), + + // 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..14d6931 --- /dev/null +++ b/lib/view/directory/directory_view.dart @@ -0,0 +1,344 @@ +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 Column( + children: [ + // Search + Filter + Toggle + Padding( + padding: MySpacing.xy(8, 10), + child: Row( + children: [ + // 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 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 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); + }, + ), + ], + )), + ), + ], + ), + ), + ], + ), + ), + + // Contact 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 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.symmetric(horizontal: 12.0, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Avatar(firstName: firstName, lastName: lastName, size: 45), + 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(6), + 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) => GestureDetector( + onTap: () => + LauncherUtils.launchPhone(p.phoneNumber), + onLongPress: () => + LauncherUtils.copyToClipboard(p.phoneNumber, + typeLabel: 'Phone number'), + child: Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.phone_outlined, + size: 16, color: Colors.indigo), + MySpacing.width(4), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 160), + child: MyText.labelSmall( + p.phoneNumber, + overflow: TextOverflow.ellipsis, + color: Colors.indigo, + decoration: TextDecoration.underline, + ), + ), + ], + ), + ), + )), + ], + ), + if (tags.isNotEmpty) ...[ + MySpacing.height(4), + 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(12), + if (phone != '-') + GestureDetector( + onTap: () => LauncherUtils.launchWhatsApp(phone), + child: const FaIcon(FontAwesomeIcons.whatsapp, + color: Colors.green, size: 20), + ), + ], + ), + ], + ), + ), + ); + }, + ); + }), + ), + + // Floating action button (moved here so it doesn't appear in NotesView) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Align( + alignment: Alignment.bottomRight, + child: 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), + ), + ), + ), + ], + ); + } +} diff --git a/lib/view/directory/notes_view.dart b/lib/view/directory/notes_view.dart new file mode 100644 index 0000000..05f59aa --- /dev/null +++ b/lib/view/directory/notes_view.dart @@ -0,0 +1,225 @@ +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; + + NotesView({super.key}) : controller = _initController(); + + static NotesController _initController() { + if (!Get.isRegistered()) { + return Get.put(NotesController()); + } + return Get.find(); + } + + 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 Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.notesList.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.note_alt_outlined, size: 60, color: Colors.grey), + MySpacing.height(12), + MyText.bodyMedium('No notes available.', fontWeight: 500), + ], + ), + ); + } + + return Padding( + padding: MySpacing.xy(16, 16), + child: ListView.separated( + itemCount: controller.notesList.length, + separatorBuilder: (_, __) => MySpacing.height(16), + itemBuilder: (_, index) { + final note = controller.notesList[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: 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( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar(firstName: initials, lastName: '', size: 36), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium( + "${note.contactName} (${note.organizationName})", + fontWeight: 600, + color: Colors.indigo[800], + ), + MySpacing.height(4), + MyText.bodySmall( + "by ${note.createdBy.firstName} • $createdDate, $createdTime", + color: Colors.grey[600], + ), + ], + ), + ), + IconButton( + icon: Icon( + isEditing ? Icons.close : Icons.edit, + size: 20, + color: Colors.indigo, + ), + onPressed: () { + controller.editingNoteId.value = + isEditing ? null : note.id; + }, + ), + ], + ), + MySpacing.height(12), + // Note 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, + ), + }, + ), + ], + ), + ); + }); + }, + ), + ); + }); + } +}