From 45ce53539c28b97be50d5b0e8abeb0601960ecc2 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 7 Jul 2025 13:41:46 +0530 Subject: [PATCH] feat(directory): enhance search functionality in Directory and Notes views --- .../directory/directory_controller.dart | 29 +- .../directory/notes_controller.dart | 13 + lib/view/directory/directory_main_screen.dart | 6 +- lib/view/directory/directory_view.dart | 98 ++++-- lib/view/directory/notes_view.dart | 325 ++++++++++-------- 5 files changed, 286 insertions(+), 185 deletions(-) diff --git a/lib/controller/directory/directory_controller.dart b/lib/controller/directory/directory_controller.dart index f078227..86e9be9 100644 --- a/lib/controller/directory/directory_controller.dart +++ b/lib/controller/directory/directory_controller.dart @@ -5,6 +5,7 @@ import 'package:marco/model/directory/contact_model.dart'; import 'package:marco/model/directory/contact_bucket_list_model.dart'; import 'package:marco/model/directory/directory_comment_model.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; + class DirectoryController extends GetxController { RxList allContacts = [].obs; RxList filteredContacts = [].obs; @@ -169,15 +170,39 @@ class DirectoryController extends GetxController { final bucketMatch = selectedBuckets.isEmpty || contact.bucketIds.any((id) => selectedBuckets.contains(id)); + // Name, org, email, phone, tags final nameMatch = contact.name.toLowerCase().contains(query); final orgMatch = contact.organization.toLowerCase().contains(query); + final emailMatch = contact.contactEmails .any((e) => e.emailAddress.toLowerCase().contains(query)); + + final phoneMatch = contact.contactPhones + .any((p) => p.phoneNumber.toLowerCase().contains(query)); + final tagMatch = contact.tags.any((tag) => tag.name.toLowerCase().contains(query)); - final searchMatch = - query.isEmpty || nameMatch || orgMatch || emailMatch || tagMatch; + final categoryNameMatch = + contact.contactCategory?.name.toLowerCase().contains(query) ?? false; + + final bucketNameMatch = contact.bucketIds.any((id) { + final bucketName = contactBuckets + .firstWhereOrNull((b) => b.id == id) + ?.name + .toLowerCase() ?? + ''; + return bucketName.contains(query); + }); + + final searchMatch = query.isEmpty || + nameMatch || + orgMatch || + emailMatch || + phoneMatch || + tagMatch || + categoryNameMatch || + bucketNameMatch; return categoryMatch && bucketMatch && searchMatch; }).toList(); diff --git a/lib/controller/directory/notes_controller.dart b/lib/controller/directory/notes_controller.dart index 462d81a..dca0201 100644 --- a/lib/controller/directory/notes_controller.dart +++ b/lib/controller/directory/notes_controller.dart @@ -8,6 +8,19 @@ class NotesController extends GetxController { RxList notesList = [].obs; RxBool isLoading = false.obs; RxnString editingNoteId = RxnString(); + RxString searchQuery = ''.obs; + + List get filteredNotesList { + if (searchQuery.isEmpty) return notesList; + + final query = searchQuery.value.toLowerCase(); + return notesList.where((note) { + return note.note.toLowerCase().contains(query) || + note.contactName.toLowerCase().contains(query) || + note.organizationName.toLowerCase().contains(query) || + note.createdBy.firstName.toLowerCase().contains(query); + }).toList(); + } @override void onInit() { diff --git a/lib/view/directory/directory_main_screen.dart b/lib/view/directory/directory_main_screen.dart index 527c6cc..04c0a0e 100644 --- a/lib/view/directory/directory_main_screen.dart +++ b/lib/view/directory/directory_main_screen.dart @@ -82,7 +82,7 @@ class DirectoryMainScreen extends StatelessWidget { children: [ // Toggle between Directory and Notes Padding( - padding: MySpacing.xy(16, 10), + padding: MySpacing.xy(8, 5), child: Obx(() { final isNotesView = controller.isNotesView.value; return Row( @@ -93,7 +93,7 @@ class DirectoryMainScreen extends StatelessWidget { onPressed: () => controller.isNotesView.value = false, icon: Icon( Icons.contacts, - color: !isNotesView ? Colors.indigo : Colors.grey, + color: !isNotesView ? Colors.red : Colors.grey, ), ), IconButton( @@ -101,7 +101,7 @@ class DirectoryMainScreen extends StatelessWidget { onPressed: () => controller.isNotesView.value = true, icon: Icon( Icons.notes, - color: isNotesView ? Colors.indigo : Colors.grey, + color: isNotesView ? Colors.red : Colors.grey, ), ), ], diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index 14d6931..d52a465 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -37,7 +37,7 @@ class DirectoryView extends StatelessWidget { // Search Field Expanded( child: SizedBox( - height: 42, + height: 35, child: TextField( controller: searchController, onChanged: (value) { @@ -45,8 +45,10 @@ class DirectoryView extends StatelessWidget { controller.applyFilters(); }, decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - prefixIcon: const Icon(Icons.search, size: 20, color: Colors.grey), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: const Icon(Icons.search, + size: 20, color: Colors.grey), hintText: 'Search contacts...', filled: true, fillColor: Colors.white, @@ -84,8 +86,8 @@ class DirectoryView extends StatelessWidget { return Stack( children: [ Container( - height: 38, - width: 38, + height: 35, + width: 35, decoration: BoxDecoration( color: Colors.white, border: Border.all(color: Colors.grey.shade300), @@ -94,13 +96,16 @@ class DirectoryView extends StatelessWidget { child: IconButton( icon: Icon(Icons.filter_alt_outlined, size: 20, - color: isFilterActive ? Colors.indigo : Colors.black87), + color: isFilterActive + ? Colors.indigo + : Colors.black87), onPressed: () { showModalBottomSheet( context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + borderRadius: BorderRadius.vertical( + top: Radius.circular(20)), ), builder: (_) => const DirectoryFilterBottomSheet(), ); @@ -126,8 +131,8 @@ class DirectoryView extends StatelessWidget { MySpacing.width(10), // 3-dot Popup with toggle Container( - height: 38, - width: 38, + height: 35, + width: 35, decoration: BoxDecoration( color: Colors.white, border: Border.all(color: Colors.grey.shade300), @@ -135,7 +140,8 @@ class DirectoryView extends StatelessWidget { ), child: PopupMenuButton( padding: EdgeInsets.zero, - icon: const Icon(Icons.more_vert, size: 20, color: Colors.black87), + icon: const Icon(Icons.more_vert, + size: 20, color: Colors.black87), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), @@ -146,7 +152,8 @@ class DirectoryView extends StatelessWidget { child: Obx(() => Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - MyText.bodySmall('Show Inactive', fontWeight: 600), + MyText.bodySmall('Show Inactive', + fontWeight: 600), Switch.adaptive( value: !controller.isActive.value, activeColor: Colors.indigo, @@ -182,7 +189,8 @@ class DirectoryView extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.contact_page_outlined, size: 60, color: Colors.grey), + const Icon(Icons.contact_page_outlined, + size: 60, color: Colors.grey), const SizedBox(height: 12), MyText.bodyMedium('No contacts found.', fontWeight: 500), ], @@ -209,18 +217,21 @@ class DirectoryView extends StatelessWidget { Get.to(() => ContactDetailScreen(contact: contact)); }, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12.0, vertical: 10), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Avatar(firstName: firstName, lastName: lastName, size: 45), + 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), + fontWeight: 600, + overflow: TextOverflow.ellipsis), MyText.bodySmall(contact.organization, color: Colors.grey[700], overflow: TextOverflow.ellipsis), @@ -228,54 +239,70 @@ class DirectoryView extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ...contact.contactEmails.map((e) => GestureDetector( - onTap: () => - LauncherUtils.launchEmail(e.emailAddress), + ...contact.contactEmails.map((e) => + GestureDetector( + onTap: () => LauncherUtils.launchEmail( + e.emailAddress), onLongPress: () => - LauncherUtils.copyToClipboard(e.emailAddress, + LauncherUtils.copyToClipboard( + e.emailAddress, typeLabel: 'Email'), child: Padding( - padding: const EdgeInsets.only(bottom: 4), + padding: + const EdgeInsets.only(bottom: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.email_outlined, - size: 16, color: Colors.indigo), + size: 16, + color: Colors.indigo), MySpacing.width(4), ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 180), + constraints: + const BoxConstraints( + maxWidth: 180), child: MyText.labelSmall( e.emailAddress, - overflow: TextOverflow.ellipsis, + overflow: + TextOverflow.ellipsis, color: Colors.indigo, - decoration: TextDecoration.underline, + decoration: + TextDecoration.underline, ), ), ], ), ), )), - ...contact.contactPhones.map((p) => GestureDetector( - onTap: () => - LauncherUtils.launchPhone(p.phoneNumber), + ...contact.contactPhones.map((p) => + GestureDetector( + onTap: () => LauncherUtils.launchPhone( + p.phoneNumber), onLongPress: () => - LauncherUtils.copyToClipboard(p.phoneNumber, + LauncherUtils.copyToClipboard( + p.phoneNumber, typeLabel: 'Phone number'), child: Padding( - padding: const EdgeInsets.only(bottom: 4), + padding: + const EdgeInsets.only(bottom: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.phone_outlined, - size: 16, color: Colors.indigo), + size: 16, + color: Colors.indigo), MySpacing.width(4), ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 160), + constraints: + const BoxConstraints( + maxWidth: 160), child: MyText.labelSmall( p.phoneNumber, - overflow: TextOverflow.ellipsis, + overflow: + TextOverflow.ellipsis, color: Colors.indigo, - decoration: TextDecoration.underline, + decoration: + TextDecoration.underline, ), ), ], @@ -302,7 +329,8 @@ class DirectoryView extends StatelessWidget { MySpacing.height(12), if (phone != '-') GestureDetector( - onTap: () => LauncherUtils.launchWhatsApp(phone), + onTap: () => + LauncherUtils.launchWhatsApp(phone), child: const FaIcon(FontAwesomeIcons.whatsapp, color: Colors.green, size: 20), ), @@ -319,7 +347,7 @@ class DirectoryView extends StatelessWidget { // Floating action button (moved here so it doesn't appear in NotesView) Padding( - padding: const EdgeInsets.only(bottom: 16.0), + padding: const EdgeInsets.only(right: 16.0, bottom: 16.0), child: Align( alignment: Alignment.bottomRight, child: FloatingActionButton( diff --git a/lib/view/directory/notes_view.dart b/lib/view/directory/notes_view.dart index 05f59aa..8745312 100644 --- a/lib/view/directory/notes_view.dart +++ b/lib/view/directory/notes_view.dart @@ -12,15 +12,18 @@ 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; + final NotesController controller = Get.find(); + final TextEditingController searchController = TextEditingController(); - NotesView({super.key}) : controller = _initController(); + NotesView({super.key}); - static NotesController _initController() { - if (!Get.isRegistered()) { - return Get.put(NotesController()); + Future _refreshNotes() async { + try { + await controller.fetchNotes(); + } catch (e, st) { + debugPrint('Error refreshing notes: $e'); + debugPrintStack(stackTrace: st); } - return Get.find(); } String _convertDeltaToHtml(dynamic delta) { @@ -69,157 +72,189 @@ class NotesView extends StatelessWidget { @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, + return Column( + children: [ + /// 🔍 Search + Refresh (Top Row) + Padding( + padding: MySpacing.xy(8, 10), + child: Row( children: [ - const Icon(Icons.note_alt_outlined, size: 60, color: Colors.grey), - MySpacing.height(12), - MyText.bodyMedium('No notes available.', fontWeight: 500), + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: searchController, + onChanged: (value) => controller.searchQuery.value = value, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 6), + prefixIcon: const Icon(Icons.search, size: 20, color: Colors.grey), + hintText: 'Search notes...', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ), + ), + ), + MySpacing.width(8), + Tooltip( + message: 'Refresh Notes', + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: _refreshNotes, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon(Icons.refresh, color: Colors.green, size: 26), + ), + ), + ), + ), ], ), - ); - } + ), - 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]; + /// 📄 Notes List View + Expanded( + child: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } - return Obx(() { - final isEditing = controller.editingNoteId.value == note.id; + final notes = controller.filteredNotesList; - 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), - ) + if (notes.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.note_alt_outlined, size: 60, color: Colors.grey), + const SizedBox(height: 12), + MyText.bodyMedium('No notes found.', fontWeight: 500), ], ), - 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], - ), - ], + ); + } + + return ListView.separated( + padding: MySpacing.only(left: 8, right: 8, top: 4, bottom: 80), + itemCount: notes.length, + separatorBuilder: (_, __) => MySpacing.height(12), + itemBuilder: (_, index) { + final note = notes[index]; + final isEditing = controller.editingNoteId.value == note.id; + + final initials = note.contactName.trim().isNotEmpty + ? note.contactName.trim().split(' ').map((e) => e[0]).take(2).join().toUpperCase() + : "NA"; + + final createdDate = DateTimeUtils.convertUtcToLocal(note.createdAt.toString(), format: 'dd MMM yyyy'); + final createdTime = DateTimeUtils.convertUtcToLocal(note.createdAt.toString(), format: 'hh:mm a'); + + final decodedDelta = HtmlToDelta().convert(note.note); + final quillController = isEditing + ? quill.QuillController( + document: quill.Document.fromDelta(decodedDelta), + selection: TextSelection.collapsed(offset: decodedDelta.length), + ) + : null; + + return AnimatedContainer( + duration: const Duration(milliseconds: 250), + padding: MySpacing.xy(12, 12), + decoration: BoxDecoration( + color: isEditing ? Colors.indigo[50] : Colors.white, + border: Border.all( + color: isEditing ? Colors.indigo : Colors.grey.shade300, + width: 1.1, + ), + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2)), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + /// Header Row + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar(firstName: initials, lastName: '', size: 40), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall( + "${note.contactName} (${note.organizationName})", + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.indigo[800], + ), + MyText.bodySmall( + "by ${note.createdBy.firstName} • $createdDate, $createdTime", + color: Colors.grey[600], + ), + ], + ), ), - ), - IconButton( - icon: Icon( - isEditing ? Icons.close : Icons.edit, - size: 20, - color: Colors.indigo, + IconButton( + icon: Icon( + isEditing ? Icons.close : Icons.edit, + color: Colors.indigo, + size: 20, + ), + onPressed: () { + controller.editingNoteId.value = isEditing ? null : note.id; + }, ), - onPressed: () { - controller.editingNoteId.value = - isEditing ? null : note.id; + ], + ), + + MySpacing.height(12), + + /// Content + if (isEditing && quillController != null) + CommentEditorCard( + controller: quillController, + onCancel: () => controller.editingNoteId.value = null, + onSave: (quillCtrl) async { + final delta = quillCtrl.document.toDelta(); + final htmlOutput = _convertDeltaToHtml(delta); + final updated = note.copyWith(note: htmlOutput); + await controller.updateNote(updated); + controller.editingNoteId.value = null; + }, + ) + else + html.Html( + data: note.note, + style: { + "body": html.Style( + margin: html.Margins.zero, + padding: html.HtmlPaddings.zero, + fontSize: html.FontSize.medium, + color: Colors.black87, + ), }, ), - ], - ), - 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, - ), - }, - ), - ], - ), - ); - }); - }, + ], + ), + ); + }, + ); + }), ), - ); - }); + ], + ); } }