feat(directory): add comment submission functionality and UI components

This commit is contained in:
Vaibhav Surve 2025-07-04 16:55:50 +05:30
parent be71544ae4
commit 549d8cce3c
5 changed files with 440 additions and 176 deletions

View File

@ -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<void> 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<DirectoryController>();
// 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()}");
}
}

View File

@ -221,6 +221,46 @@ class ApiService {
} }
/// Directory calling the API /// Directory calling the API
static Future<bool> 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<bool> updateContactComment( static Future<bool> updateContactComment(
String commentId, String note, String contactId) async { String commentId, String note, String contactId) async {
final payload = { final payload = {

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:flutter_quill/flutter_quill.dart' as quill;
import 'package:get/get.dart';
class CommentEditorCard extends StatelessWidget { class CommentEditorCard extends StatelessWidget {
final quill.QuillController controller; final quill.QuillController controller;
@ -16,60 +15,39 @@ class CommentEditorCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final RxBool _showFullToolbar = false.obs;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Obx(() { quill.QuillSimpleToolbar(
final showFull = _showFullToolbar.value; controller: controller,
configurations: const quill.QuillSimpleToolbarConfigurations(
return Column( showBoldButton: true,
crossAxisAlignment: CrossAxisAlignment.start, showItalicButton: true,
children: [ showUnderLineButton: true,
quill.QuillSimpleToolbar( showListBullets: true,
controller: controller, showListNumbers: true,
configurations: quill.QuillSimpleToolbarConfigurations( showAlignmentButtons: true,
showBoldButton: true, showLink: true,
showItalicButton: true, showFontSize: false,
showUnderLineButton: showFull, showFontFamily: false,
showListBullets: true, showColorButton: false,
showListNumbers: true, showBackgroundColorButton: false,
showAlignmentButtons: showFull, showUndo: false,
showLink: true, showRedo: false,
showFontSize: showFull, showCodeBlock: false,
showFontFamily: showFull, showQuote: false,
showColorButton: showFull, showSuperscript: false,
showBackgroundColorButton: showFull, showSubscript: false,
showUndo: false, showInlineCode: false,
showRedo: false, showDirection: false,
showCodeBlock: showFull, showListCheck: false,
showQuote: showFull, showStrikeThrough: false,
showSuperscript: false, showClearFormat: false,
showSubscript: false, showDividers: false,
showInlineCode: false, showHeaderStyle: false,
showDirection: false, multiRowsDisplay: 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),
),
),
)
],
);
}),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
height: 120, height: 120,

View File

@ -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<AddCommentBottomSheet> createState() => _AddCommentBottomSheetState();
}
class _AddCommentBottomSheetState extends State<AddCommentBottomSheet> {
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<AddCommentController>();
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 <ul> if list item starts
if (isListItem && !inList) {
buffer.write('<ul>');
inList = true;
}
// Close <ul> if list ended
if (!isListItem && inList) {
buffer.write('</ul>');
inList = false;
}
// Skip empty list items
final trimmedData = data.trim();
if (isListItem && trimmedData.isEmpty) {
// don't write empty <li>
continue;
}
if (isListItem) buffer.write('<li>');
if (attr.containsKey('bold')) buffer.write('<strong>');
if (attr.containsKey('italic')) buffer.write('<em>');
if (attr.containsKey('underline')) buffer.write('<u>');
if (attr.containsKey('strike')) buffer.write('<s>');
if (attr.containsKey('link')) buffer.write('<a href="${attr['link']}">');
// Use trimmedData instead of raw data (removes trailing/leading spaces/newlines)
buffer.write(trimmedData.replaceAll('\n', ''));
if (attr.containsKey('link')) buffer.write('</a>');
if (attr.containsKey('strike')) buffer.write('</s>');
if (attr.containsKey('underline')) buffer.write('</u>');
if (attr.containsKey('italic')) buffer.write('</em>');
if (attr.containsKey('bold')) buffer.write('</strong>');
if (isListItem) {
buffer.write('</li>');
} else if (data.contains('\n')) {
buffer.write('<br>');
}
}
if (inList) buffer.write('</ul>');
return buffer.toString();
}

View File

@ -13,6 +13,7 @@ import 'package:marco/helpers/utils/launcher_utils.dart';
import 'package:tab_indicator_styler/tab_indicator_styler.dart'; import 'package:tab_indicator_styler/tab_indicator_styler.dart';
import 'package:marco/helpers/widgets/Directory/comment_editor_card.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: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 { class ContactDetailScreen extends StatefulWidget {
final ContactModel contact; final ContactModel contact;
@ -62,7 +63,8 @@ String _convertDeltaToHtml(dynamic delta) {
if (attr.containsKey('italic')) buffer.write('</em>'); if (attr.containsKey('italic')) buffer.write('</em>');
if (attr.containsKey('bold')) buffer.write('</strong>'); if (attr.containsKey('bold')) buffer.write('</strong>');
if (isListItem) buffer.write('</li>'); if (isListItem)
buffer.write('</li>');
else if (data.contains('\n')) buffer.write('<br>'); else if (data.contains('\n')) buffer.write('<br>');
} }
@ -71,7 +73,6 @@ String _convertDeltaToHtml(dynamic delta) {
return buffer.toString(); return buffer.toString();
} }
class _ContactDetailScreenState extends State<ContactDetailScreen> { class _ContactDetailScreenState extends State<ContactDetailScreen> {
late final DirectoryController directoryController; late final DirectoryController directoryController;
late final ProjectController projectController; late final ProjectController projectController;
@ -203,10 +204,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
], ],
), ),
TabBar( TabBar(
labelColor: Colors.indigo, labelColor: Colors.red,
unselectedLabelColor: Colors.grey, unselectedLabelColor: Colors.black,
indicator: MaterialIndicator( indicator: MaterialIndicator(
color: Colors.indigo, color: Colors.red,
height: 4, height: 4,
topLeftRadius: 8, topLeftRadius: 8,
topRightRadius: 8, topRightRadius: 8,
@ -232,8 +233,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
? widget.contact.contactPhones.first.phoneNumber ? widget.contact.contactPhones.first.phoneNumber
: "-"; : "-";
final createdDate = final createdDate = DateTime.now();
DateTime.now(); // TODO: Replace with actual creation date if available
final formattedDate = DateFormat('MMMM dd, yyyy').format(createdDate); final formattedDate = DateFormat('MMMM dd, yyyy').format(createdDate);
final tags = widget.contact.tags.map((e) => e.name).join(", "); final tags = widget.contact.tags.map((e) => e.name).join(", ");
@ -301,133 +301,163 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
Widget _buildCommentsTab(BuildContext context) { Widget _buildCommentsTab(BuildContext context) {
return Obx(() { return Obx(() {
if (!directoryController.contactCommentsMap final contactId = widget.contact.id;
.containsKey(widget.contact.id)) {
if (!directoryController.contactCommentsMap.containsKey(contactId)) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final comments = final comments = directoryController
directoryController.getCommentsForContact(widget.contact.id); .getCommentsForContact(contactId)
.reversed
.toList();
final editingId = directoryController.editingCommentId.value; final editingId = directoryController.editingCommentId.value;
if (comments.isEmpty) { return Stack(
return Center( children: [
child: MyText.bodyLarge("No comments yet.", color: Colors.grey), comments.isEmpty
); ? 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),
) )
: 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( final initials = comment.createdBy.firstName.isNotEmpty
padding: MySpacing.xy(14, 12), ? comment.createdBy.firstName[0].toUpperCase()
decoration: BoxDecoration( : "?";
color: Colors.white,
borderRadius: BorderRadius.circular(12), final decodedDelta = HtmlToDelta().convert(comment.note);
boxShadow: const [
BoxShadow( final quillController = isEditing
color: Colors.black12, ? quill.QuillController(
blurRadius: 4, document: quill.Document.fromDelta(decodedDelta),
offset: Offset(0, 2), selection: TextSelection.collapsed(
) offset: decodedDelta.length),
], )
), : null;
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, return Container(
children: [ padding: MySpacing.xy(14, 12),
Row( decoration: BoxDecoration(
children: [ color: Colors.white,
Avatar(firstName: initials, lastName: '', size: 31), borderRadius: BorderRadius.circular(12),
MySpacing.width(8), boxShadow: const [
Expanded( BoxShadow(
child: Column( color: Colors.black12,
crossAxisAlignment: CrossAxisAlignment.start, blurRadius: 4,
children: [ offset: Offset(0, 2),
MyText.bodySmall("By: ${comment.createdBy.firstName}", )
fontWeight: 600, color: Colors.indigo[700]), ],
MySpacing.height(2), ),
MyText.bodySmall( child: Column(
DateFormat('dd MMM yyyy, hh:mm a') crossAxisAlignment: CrossAxisAlignment.start,
.format(comment.createdAt), children: [
fontWeight: 500, Row(
color: Colors.grey[600], children: [
), Avatar(
], firstName: initials,
), lastName: '',
), size: 31),
IconButton( MySpacing.width(8),
icon: Icon(isEditing ? Icons.close : Icons.edit, Expanded(
size: 20, color: Colors.grey[700]), child: Column(
onPressed: () { crossAxisAlignment:
directoryController.editingCommentId.value = CrossAxisAlignment.start,
isEditing ? null : comment.id; children: [
}, MyText.bodySmall(
), "By: ${comment.createdBy.firstName}",
], fontWeight: 600,
), color: Colors.indigo[700]),
MySpacing.height(10), MySpacing.height(2),
if (isEditing && quillController != null) MyText.bodySmall(
CommentEditorCard( DateFormat('dd MMM yyyy, hh:mm a')
controller: quillController, .format(comment.createdAt),
onCancel: () { fontWeight: 500,
directoryController.editingCommentId.value = null; color: Colors.grey[600],
}, ),
onSave: (controller) async { ],
final delta = controller.document.toDelta(); ),
final htmlOutput = _convertDeltaToHtml(delta); ),
final updated = comment.copyWith(note: htmlOutput); IconButton(
await directoryController.updateComment(updated); icon: Icon(
directoryController.editingCommentId.value = null; isEditing ? Icons.close : Icons.edit,
}) size: 20,
else color: Colors.grey[700]),
html.Html( onPressed: () {
data: comment.note, directoryController.editingCommentId.value =
style: { isEditing ? null : comment.id;
"body": html.Style( },
margin: html.Margins.all(0), ),
padding: html.HtmlPaddings.all(0), ],
fontSize: html.FontSize.medium, ),
color: Colors.black87, MySpacing.height(10),
), if (isEditing && quillController != null)
"pre": html.Style( CommentEditorCard(
padding: html.HtmlPaddings.all(8), controller: quillController,
fontSize: html.FontSize.small, onCancel: () {
fontFamily: 'monospace', directoryController.editingCommentId.value =
backgroundColor: const Color(0xFFF1F1F1), null;
border: Border.all(color: Colors.grey.shade300), },
), onSave: (controller) async {
"h3": html.Style( final delta = controller.document.toDelta();
fontSize: html.FontSize.large, final htmlOutput =
fontWeight: FontWeight.bold, _convertDeltaToHtml(delta);
color: Colors.indigo[700], final updated =
), comment.copyWith(note: htmlOutput);
"strong": html.Style(fontWeight: FontWeight.w700), await directoryController
"p": html.Style(margin: html.Margins.only(bottom: 8)), .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)),
),
), ),
); ],
},
); );
}); });
} }