diff --git a/lib/controller/directory/add_comment_controller.dart b/lib/controller/directory/add_comment_controller.dart new file mode 100644 index 0000000..bc77c50 --- /dev/null +++ b/lib/controller/directory/add_comment_controller.dart @@ -0,0 +1,74 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/controller/directory/directory_controller.dart'; + +class AddCommentController extends GetxController { + final String contactId; + + AddCommentController({required this.contactId}); + + final RxString note = ''.obs; + final RxBool isSubmitting = false.obs; + + Future submitComment() async { + if (note.value.trim().isEmpty) { + showAppSnackbar( + title: "Validation", + message: "Comment cannot be empty.", + type: SnackbarType.warning, + ); + return; + } + + isSubmitting.value = true; + try { + logSafe("Submitting comment for contactId: $contactId"); + + final success = await ApiService.addContactComment( + note.value.trim(), + contactId, + ); + + if (success) { + logSafe("Comment added successfully."); + + // Get the directory controller + final directoryController = Get.find(); + + // Fetch latest comments for the contact to refresh UI + await directoryController.fetchCommentsForContact(contactId); + + Get.back(result: true); + + showAppSnackbar( + title: "Success", + message: "Comment added successfully.", + type: SnackbarType.success, + ); + } else { + logSafe("Comment submission failed", level: LogLevel.error); + showAppSnackbar( + title: "Error", + message: "Failed to add comment.", + type: SnackbarType.error, + ); + } + } catch (e) { + logSafe("Error while submitting comment: $e", level: LogLevel.error); + showAppSnackbar( + title: "Error", + message: "Something went wrong.", + type: SnackbarType.error, + ); + } finally { + isSubmitting.value = false; + } + } + + void updateNote(String value) { + note.value = value; + logSafe("Note updated: ${value.trim()}"); + } +} diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index b5cb819..d894c98 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -221,6 +221,46 @@ class ApiService { } /// Directory calling the API + static Future addContactComment(String note, String contactId) async { + final payload = { + "note": note, + "contactId": contactId, + }; + + final endpoint = ApiEndpoints.updateDirectoryNotes; + + logSafe("Adding new comment with payload: $payload"); + logSafe("Sending add comment request to $endpoint"); + + try { + final response = await _postRequest(endpoint, payload); + + if (response == null) { + logSafe("Add comment failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Add comment response status: ${response.statusCode}"); + logSafe("Add comment response body: ${response.body}"); + + final json = jsonDecode(response.body); + + if (json['success'] == true) { + logSafe("Comment added successfully for contactId: $contactId"); + return true; + } else { + logSafe("Failed to add comment: ${json['message']}", + level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception during addComment API: ${e.toString()}", + level: LogLevel.error); + logSafe("StackTrace: ${stack.toString()}", level: LogLevel.debug); + } + + return false; + } + static Future updateContactComment( String commentId, String note, String contactId) async { final payload = { diff --git a/lib/helpers/widgets/Directory/comment_editor_card.dart b/lib/helpers/widgets/Directory/comment_editor_card.dart index 4c26dbf..57c71e3 100644 --- a/lib/helpers/widgets/Directory/comment_editor_card.dart +++ b/lib/helpers/widgets/Directory/comment_editor_card.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; -import 'package:get/get.dart'; class CommentEditorCard extends StatelessWidget { final quill.QuillController controller; @@ -16,60 +15,39 @@ class CommentEditorCard extends StatelessWidget { @override Widget build(BuildContext context) { - final RxBool _showFullToolbar = false.obs; - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Obx(() { - final showFull = _showFullToolbar.value; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - quill.QuillSimpleToolbar( - controller: controller, - configurations: quill.QuillSimpleToolbarConfigurations( - showBoldButton: true, - showItalicButton: true, - showUnderLineButton: showFull, - showListBullets: true, - showListNumbers: true, - showAlignmentButtons: showFull, - showLink: true, - showFontSize: showFull, - showFontFamily: showFull, - showColorButton: showFull, - showBackgroundColorButton: showFull, - showUndo: false, - showRedo: false, - showCodeBlock: showFull, - showQuote: showFull, - showSuperscript: false, - showSubscript: false, - showInlineCode: false, - showDirection: false, - showListCheck: false, - showStrikeThrough: false, - showClearFormat: showFull, - showDividers: false, - showHeaderStyle: showFull, - multiRowsDisplay: false, - ), - ), - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () => _showFullToolbar.toggle(), - child: Text( - showFull ? "Hide Formatting" : "More Formatting", - style: const TextStyle(color: Colors.indigo), - ), - ), - ) - ], - ); - }), + quill.QuillSimpleToolbar( + controller: controller, + configurations: const quill.QuillSimpleToolbarConfigurations( + showBoldButton: true, + showItalicButton: true, + showUnderLineButton: true, + showListBullets: true, + showListNumbers: true, + showAlignmentButtons: true, + showLink: true, + showFontSize: false, + showFontFamily: false, + showColorButton: false, + showBackgroundColorButton: false, + showUndo: false, + showRedo: false, + showCodeBlock: false, + showQuote: false, + showSuperscript: false, + showSubscript: false, + showInlineCode: false, + showDirection: false, + showListCheck: false, + showStrikeThrough: false, + showClearFormat: false, + showDividers: false, + showHeaderStyle: false, + multiRowsDisplay: false, + ), + ), const SizedBox(height: 8), Container( height: 120, diff --git a/lib/model/directory/add_comment_bottom_sheet.dart b/lib/model/directory/add_comment_bottom_sheet.dart new file mode 100644 index 0000000..e06b47e --- /dev/null +++ b/lib/model/directory/add_comment_bottom_sheet.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_quill/flutter_quill.dart' as quill; +import 'package:marco/controller/directory/add_comment_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart'; + +class AddCommentBottomSheet extends StatefulWidget { + final String contactId; + + const AddCommentBottomSheet({super.key, required this.contactId}); + + @override + State createState() => _AddCommentBottomSheetState(); +} + +class _AddCommentBottomSheetState extends State { + late final AddCommentController controller; + late final quill.QuillController quillController; + + @override + void initState() { + super.initState(); + controller = Get.put(AddCommentController(contactId: widget.contactId)); + // Initialize empty editor for new comment + quillController = quill.QuillController.basic(); + } + + @override + void dispose() { + quillController.dispose(); + Get.delete(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: MediaQuery.of(context).viewInsets, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: const [ + BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, -2)), + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 40, + height: 5, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(10), + ), + ), + ), + MySpacing.height(12), + Center(child: MyText.titleMedium("Add Comment", fontWeight: 700)), + MySpacing.height(24), + CommentEditorCard( + controller: quillController, + onCancel: () => Get.back(), + onSave: (controller) async { + final delta = controller.document.toDelta(); + final htmlOutput = _convertDeltaToHtml(delta); + this.controller.updateNote(htmlOutput); + await this.controller.submitComment(); + if (mounted) Get.back(); + }, + ), + ], + ), + ), + ), + ); + } +} + +String _convertDeltaToHtml(dynamic delta) { + final buffer = StringBuffer(); + bool inList = false; + + for (var op in delta.toList()) { + final data = op.data?.toString() ?? ''; + final attr = op.attributes ?? {}; + + final isListItem = attr.containsKey('list'); + + // Start
    if list item starts + if (isListItem && !inList) { + buffer.write('
      '); + inList = true; + } + + // Close
        if list ended + if (!isListItem && inList) { + buffer.write('
      '); + inList = false; + } + + // Skip empty list items + final trimmedData = data.trim(); + if (isListItem && trimmedData.isEmpty) { + // don't write empty
    • + continue; + } + + if (isListItem) buffer.write('
    • '); + + if (attr.containsKey('bold')) buffer.write(''); + if (attr.containsKey('italic')) buffer.write(''); + if (attr.containsKey('underline')) buffer.write(''); + if (attr.containsKey('strike')) buffer.write(''); + if (attr.containsKey('link')) buffer.write(''); + + // Use trimmedData instead of raw data (removes trailing/leading spaces/newlines) + buffer.write(trimmedData.replaceAll('\n', '')); + + if (attr.containsKey('link')) buffer.write(''); + if (attr.containsKey('strike')) buffer.write(''); + if (attr.containsKey('underline')) buffer.write(''); + if (attr.containsKey('italic')) buffer.write(''); + if (attr.containsKey('bold')) buffer.write(''); + + if (isListItem) { + buffer.write('
    • '); + } else if (data.contains('\n')) { + buffer.write('
      '); + } + } + + if (inList) buffer.write('
    '); + + return buffer.toString(); +} diff --git a/lib/view/directory/contact_detail_screen.dart b/lib/view/directory/contact_detail_screen.dart index 71e193c..a84ad40 100644 --- a/lib/view/directory/contact_detail_screen.dart +++ b/lib/view/directory/contact_detail_screen.dart @@ -13,6 +13,7 @@ import 'package:marco/helpers/utils/launcher_utils.dart'; import 'package:tab_indicator_styler/tab_indicator_styler.dart'; import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart'; import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart'; +import 'package:marco/model/directory/add_comment_bottom_sheet.dart'; class ContactDetailScreen extends StatefulWidget { final ContactModel contact; @@ -62,7 +63,8 @@ String _convertDeltaToHtml(dynamic delta) { if (attr.containsKey('italic')) buffer.write(''); if (attr.containsKey('bold')) buffer.write(''); - if (isListItem) buffer.write(''); + if (isListItem) + buffer.write(''); else if (data.contains('\n')) buffer.write('
    '); } @@ -71,7 +73,6 @@ String _convertDeltaToHtml(dynamic delta) { return buffer.toString(); } - class _ContactDetailScreenState extends State { late final DirectoryController directoryController; late final ProjectController projectController; @@ -203,10 +204,10 @@ class _ContactDetailScreenState extends State { ], ), TabBar( - labelColor: Colors.indigo, - unselectedLabelColor: Colors.grey, + labelColor: Colors.red, + unselectedLabelColor: Colors.black, indicator: MaterialIndicator( - color: Colors.indigo, + color: Colors.red, height: 4, topLeftRadius: 8, topRightRadius: 8, @@ -232,8 +233,7 @@ class _ContactDetailScreenState extends State { ? widget.contact.contactPhones.first.phoneNumber : "-"; - final createdDate = - DateTime.now(); // TODO: Replace with actual creation date if available + final createdDate = DateTime.now(); final formattedDate = DateFormat('MMMM dd, yyyy').format(createdDate); final tags = widget.contact.tags.map((e) => e.name).join(", "); @@ -301,133 +301,163 @@ class _ContactDetailScreenState extends State { Widget _buildCommentsTab(BuildContext context) { return Obx(() { - if (!directoryController.contactCommentsMap - .containsKey(widget.contact.id)) { + final contactId = widget.contact.id; + + if (!directoryController.contactCommentsMap.containsKey(contactId)) { return const Center(child: CircularProgressIndicator()); } - final comments = - directoryController.getCommentsForContact(widget.contact.id); + final comments = directoryController + .getCommentsForContact(contactId) + .reversed + .toList(); + final editingId = directoryController.editingCommentId.value; - if (comments.isEmpty) { - return Center( - child: MyText.bodyLarge("No comments yet.", color: Colors.grey), - ); - } - - return ListView.separated( - padding: MySpacing.xy(8, 8), - itemCount: comments.length, - separatorBuilder: (_, __) => MySpacing.height(12), - itemBuilder: (_, index) { - final comment = comments[index]; - final isEditing = editingId == comment.id; - - final initials = comment.createdBy.firstName.isNotEmpty - ? comment.createdBy.firstName[0].toUpperCase() - : "?"; - - final decodedDelta = HtmlToDelta().convert(comment.note); - - final quillController = isEditing - ? quill.QuillController( - document: quill.Document.fromDelta(decodedDelta), - selection: - TextSelection.collapsed(offset: decodedDelta.length), + return Stack( + children: [ + comments.isEmpty + ? Center( + child: + MyText.bodyLarge("No comments yet.", color: Colors.grey), ) - : null; + : Padding( + padding: MySpacing.xy(8, 8), // Same padding as Details tab + child: ListView.separated( + padding: EdgeInsets.only( + bottom: 80, // Extra bottom padding to avoid FAB overlap + ), + itemCount: comments.length, + separatorBuilder: (_, __) => MySpacing.height(12), + itemBuilder: (_, index) { + final comment = comments[index]; + final isEditing = editingId == comment.id; - 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: [ - Row( - children: [ - Avatar(firstName: initials, lastName: '', size: 31), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodySmall("By: ${comment.createdBy.firstName}", - fontWeight: 600, color: Colors.indigo[700]), - MySpacing.height(2), - MyText.bodySmall( - DateFormat('dd MMM yyyy, hh:mm a') - .format(comment.createdAt), - fontWeight: 500, - color: Colors.grey[600], - ), - ], - ), - ), - IconButton( - icon: Icon(isEditing ? Icons.close : Icons.edit, - size: 20, color: Colors.grey[700]), - onPressed: () { - directoryController.editingCommentId.value = - isEditing ? null : comment.id; - }, - ), - ], - ), - MySpacing.height(10), - if (isEditing && quillController != null) - CommentEditorCard( - controller: quillController, - onCancel: () { - directoryController.editingCommentId.value = null; - }, - onSave: (controller) async { - final delta = controller.document.toDelta(); - final htmlOutput = _convertDeltaToHtml(delta); - final updated = comment.copyWith(note: htmlOutput); - await directoryController.updateComment(updated); - directoryController.editingCommentId.value = null; - }) - else - html.Html( - data: comment.note, - style: { - "body": html.Style( - margin: html.Margins.all(0), - padding: html.HtmlPaddings.all(0), - fontSize: html.FontSize.medium, - color: Colors.black87, - ), - "pre": html.Style( - padding: html.HtmlPaddings.all(8), - fontSize: html.FontSize.small, - fontFamily: 'monospace', - backgroundColor: const Color(0xFFF1F1F1), - border: Border.all(color: Colors.grey.shade300), - ), - "h3": html.Style( - fontSize: html.FontSize.large, - fontWeight: FontWeight.bold, - color: Colors.indigo[700], - ), - "strong": html.Style(fontWeight: FontWeight.w700), - "p": html.Style(margin: html.Margins.only(bottom: 8)), + final initials = comment.createdBy.firstName.isNotEmpty + ? comment.createdBy.firstName[0].toUpperCase() + : "?"; + + final decodedDelta = HtmlToDelta().convert(comment.note); + + final quillController = isEditing + ? quill.QuillController( + document: quill.Document.fromDelta(decodedDelta), + selection: TextSelection.collapsed( + offset: decodedDelta.length), + ) + : null; + + return 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: [ + Row( + children: [ + Avatar( + firstName: initials, + lastName: '', + size: 31), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + MyText.bodySmall( + "By: ${comment.createdBy.firstName}", + fontWeight: 600, + color: Colors.indigo[700]), + MySpacing.height(2), + MyText.bodySmall( + DateFormat('dd MMM yyyy, hh:mm a') + .format(comment.createdAt), + fontWeight: 500, + color: Colors.grey[600], + ), + ], + ), + ), + IconButton( + icon: Icon( + isEditing ? Icons.close : Icons.edit, + size: 20, + color: Colors.grey[700]), + onPressed: () { + directoryController.editingCommentId.value = + isEditing ? null : comment.id; + }, + ), + ], + ), + MySpacing.height(10), + if (isEditing && quillController != null) + CommentEditorCard( + controller: quillController, + onCancel: () { + directoryController.editingCommentId.value = + null; + }, + onSave: (controller) async { + final delta = controller.document.toDelta(); + final htmlOutput = + _convertDeltaToHtml(delta); + final updated = + comment.copyWith(note: htmlOutput); + await directoryController + .updateComment(updated); + directoryController.editingCommentId.value = + null; + }) + else + html.Html( + data: comment.note, + style: { + "body": html.Style( + margin: html.Margins.all(0), + padding: html.HtmlPaddings.all(0), + fontSize: html.FontSize.medium, + color: Colors.black87, + ), + }, + ), + ], + ), + ); }, ), - ], + ), + + // Floating Action Button to Add Comment + if (directoryController.editingCommentId.value == null) + Positioned( + bottom: 16, + right: 16, + child: FloatingActionButton.extended( + backgroundColor: Colors.red, + onPressed: () { + Get.bottomSheet( + AddCommentBottomSheet(contactId: contactId), + isScrollControlled: true, + ); + }, + icon: const Icon(Icons.add_comment, color: Colors.white), + label: const Text("Add Comment", + style: TextStyle(color: Colors.white)), + ), ), - ); - }, + ], ); }); }