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
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(
String commentId, String note, String contactId) async {
final payload = {

View File

@ -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,

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: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('</em>');
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>');
}
@ -71,7 +73,6 @@ String _convertDeltaToHtml(dynamic delta) {
return buffer.toString();
}
class _ContactDetailScreenState extends State<ContactDetailScreen> {
late final DirectoryController directoryController;
late final ProjectController projectController;
@ -203,10 +204,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
],
),
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<ContactDetailScreen> {
? 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<ContactDetailScreen> {
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)),
),
),
);
},
],
);
});
}