import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:flutter_html/flutter_html.dart' as html; import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/directory/directory_controller.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/model/directory/contact_model.dart'; import 'package:marco/helpers/widgets/avatar.dart'; 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'; import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/model/directory/add_contact_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; // HELPER: Delta to HTML conversion String _convertDeltaToHtml(dynamic delta) { final buffer = StringBuffer(); bool inList = false; for (var op in delta.toList()) { final String data = op.data?.toString() ?? ''; final attr = op.attributes ?? {}; final bool isListItem = attr.containsKey('list'); 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(); } class ContactDetailScreen extends StatefulWidget { final ContactModel contact; const ContactDetailScreen({super.key, required this.contact}); @override State createState() => _ContactDetailScreenState(); } class _ContactDetailScreenState extends State { late final DirectoryController directoryController; late final ProjectController projectController; late Rx contactRx; @override void initState() { super.initState(); directoryController = Get.find(); projectController = Get.find(); contactRx = widget.contact.obs; WidgetsBinding.instance.addPostFrameCallback((_) async { await directoryController.fetchCommentsForContact(contactRx.value.id, active: true); await directoryController.fetchCommentsForContact(contactRx.value.id, active: false); }); // Listen to controller's allContacts and update contact if changed ever(directoryController.allContacts, (_) { final updated = directoryController.allContacts .firstWhereOrNull((c) => c.id == contactRx.value.id); if (updated != null) contactRx.value = updated; }); } @override Widget build(BuildContext context) { return DefaultTabController( length: 2, child: Scaffold( backgroundColor: const Color(0xFFF5F5F5), appBar: _buildMainAppBar(), body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Obx(() => _buildSubHeader(contactRx.value)), const Divider(height: 1, thickness: 0.5, color: Colors.grey), Expanded( child: TabBarView(children: [ Obx(() => _buildDetailsTab(contactRx.value)), _buildCommentsTab(), ]), ), ], ), ), ), ); } PreferredSizeWidget _buildMainAppBar() { return AppBar( backgroundColor: const Color(0xFFF5F5F5), elevation: 0.2, automaticallyImplyLeading: false, titleSpacing: 0, title: Padding( padding: MySpacing.xy(16, 0), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), onPressed: () => Get.offAllNamed('/dashboard/directory-main-page'), ), MySpacing.width(8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ MyText.titleLarge('Contact Profile', fontWeight: 700, color: Colors.black), MySpacing.height(2), GetBuilder(builder: (p) { return ProjectLabel(p.selectedProject?.name); }), ], ), ), ], ), ), ); } Widget _buildSubHeader(ContactModel contact) { final firstName = contact.name.split(" ").first; final lastName = contact.name.split(" ").length > 1 ? contact.name.split(" ").last : ""; return Padding( padding: MySpacing.xy(16, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Avatar( firstName: firstName, lastName: lastName, size: 35, ), MySpacing.width(12), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.titleSmall(contact.name, fontWeight: 600, color: Colors.black), MySpacing.height(2), MyText.bodySmall(contact.organization, fontWeight: 500, color: Colors.grey[700]), ], ), ]), TabBar( labelColor: Colors.red, unselectedLabelColor: Colors.black, indicator: MaterialIndicator( color: Colors.red, height: 4, topLeftRadius: 8, topRightRadius: 8, bottomLeftRadius: 8, bottomRightRadius: 8, ), tabs: const [ Tab(text: "Details"), Tab(text: "Notes"), ], ), ], ), ); } Widget _buildDetailsTab(ContactModel contact) { final tags = contact.tags.map((e) => e.name).join(", "); final bucketNames = contact.bucketIds .map((id) => directoryController.contactBuckets .firstWhereOrNull((b) => b.id == id) ?.name) .whereType() .join(", "); final projectNames = contact.projectIds ?.map((id) => projectController.projects .firstWhereOrNull((p) => p.id == id) ?.name) .whereType() .join(", ") ?? "-"; final category = contact.contactCategory?.name ?? "-"; Widget multiRows( {required List items, required IconData icon, required String label, required String typeLabel, required Function(String)? onTap, required Function(String)? onLongPress}) { return items.isNotEmpty ? Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _iconInfoRow(icon, label, items.first, onTap: () => onTap?.call(items.first), onLongPress: () => onLongPress?.call(items.first)), ...items.skip(1).map( (val) => _iconInfoRow( null, '', val, onTap: () => onTap?.call(val), onLongPress: () => onLongPress?.call(val), ), ), ], ) : _iconInfoRow(icon, label, "-"); } return Stack( children: [ SingleChildScrollView( padding: MySpacing.fromLTRB(8, 8, 8, 80), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MySpacing.height(12), _infoCard("Basic Info", [ multiRows( items: contact.contactEmails.map((e) => e.emailAddress).toList(), icon: Icons.email, label: "Email", typeLabel: "Email", onTap: (email) => LauncherUtils.launchEmail(email), onLongPress: (email) => LauncherUtils.copyToClipboard(email, typeLabel: "Email"), ), multiRows( items: contact.contactPhones.map((p) => p.phoneNumber).toList(), icon: Icons.phone, label: "Phone", typeLabel: "Phone", onTap: (phone) => LauncherUtils.launchPhone(phone), onLongPress: (phone) => LauncherUtils.copyToClipboard(phone, typeLabel: "Phone"), ), _iconInfoRow(Icons.location_on, "Address", contact.address), ]), _infoCard("Organization", [ _iconInfoRow( Icons.business, "Organization", contact.organization), _iconInfoRow(Icons.category, "Category", category), ]), _infoCard("Meta Info", [ _iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"), _iconInfoRow(Icons.folder_shared, "Contact Buckets", bucketNames.isNotEmpty ? bucketNames : "-"), _iconInfoRow(Icons.work_outline, "Projects", projectNames), ]), _infoCard("Description", [ MySpacing.height(6), Align( alignment: Alignment.topLeft, child: MyText.bodyMedium( contact.description, color: Colors.grey[800], maxLines: 10, textAlign: TextAlign.left, ), ), ]), ], ), ), Positioned( bottom: 20, right: 20, child: FloatingActionButton.extended( backgroundColor: Colors.red, onPressed: () async { final result = await Get.bottomSheet( AddContactBottomSheet(existingContact: contact), isScrollControlled: true, backgroundColor: Colors.transparent, ); if (result == true) { await directoryController.fetchContacts(); final updated = directoryController.allContacts .firstWhereOrNull((c) => c.id == contact.id); if (updated != null) { contactRx.value = updated; } } }, icon: const Icon(Icons.edit, color: Colors.white), label: const Text("Edit Contact", style: TextStyle(color: Colors.white)), ), ), ], ); } Widget _buildCommentsTab() { return Obx(() { final contactId = contactRx.value.id; // Get active and inactive comments final activeComments = directoryController .getCommentsForContact(contactId) .where((c) => c.isActive) .toList(); final inactiveComments = directoryController .getCommentsForContact(contactId) .where((c) => !c.isActive) .toList(); // Combine both and keep the same sorting (recent first) final comments = [...activeComments, ...inactiveComments].reversed.toList(); final editingId = directoryController.editingCommentId.value; if (comments.isEmpty) { return Center( child: MyText.bodyLarge("No notes yet.", color: Colors.grey), ); } return Stack( children: [ MyRefreshIndicator( onRefresh: () async { await directoryController.fetchCommentsForContact(contactId, active: true); await directoryController.fetchCommentsForContact(contactId, active: false); }, child: Padding( padding: MySpacing.xy(12, 12), child: ListView.separated( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.only(bottom: 100), itemCount: comments.length, separatorBuilder: (_, __) => MySpacing.height(14), itemBuilder: (_, index) => _buildCommentItem(comments[index], editingId, contactId), ), ), ), if (editingId == null) Positioned( bottom: 20, right: 20, child: FloatingActionButton.extended( backgroundColor: Colors.red, onPressed: () async { final result = await Get.bottomSheet( AddCommentBottomSheet(contactId: contactId), isScrollControlled: true, ); if (result == true) { await directoryController.fetchCommentsForContact(contactId, active: true); await directoryController.fetchCommentsForContact(contactId, active: false); } }, icon: const Icon(Icons.add_comment, color: Colors.white), label: const Text("Add Note", style: TextStyle(color: Colors.white)), ), ), ], ); }); } Widget _buildCommentItem(comment, editingId, contactId) { 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; return Container( margin: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: Colors.grey.shade200), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.03), blurRadius: 6, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 🧑 Header Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Avatar( firstName: initials, lastName: '', size: 40, ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Full name on top Text( "${comment.createdBy.firstName} ${comment.createdBy.lastName}", style: const TextStyle( fontWeight: FontWeight.w700, fontSize: 15, color: Colors.black87, ), overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), // Job Role if (comment.createdBy.jobRoleName?.isNotEmpty ?? false) Text( comment.createdBy.jobRoleName, style: TextStyle( fontSize: 13, color: Colors.indigo[600], fontWeight: FontWeight.w500, ), ), const SizedBox(height: 2), // Timestamp Text( DateTimeUtils.convertUtcToLocal( comment.createdAt.toString(), format: 'dd MMM yyyy, hh:mm a', ), style: TextStyle( fontSize: 12, color: Colors.grey[600], ), ), ], ), ), // ⚡ Action buttons Row( mainAxisSize: MainAxisSize.min, children: [ if (!comment.isActive) IconButton( icon: const Icon(Icons.restore, size: 18, color: Colors.green), tooltip: "Restore", splashRadius: 18, onPressed: () async { await Get.dialog( ConfirmDialog( title: "Restore Note", message: "Are you sure you want to restore this note?", confirmText: "Restore", confirmColor: Colors.green, icon: Icons.restore, onConfirm: () async { await directoryController.restoreComment( comment.id, contactId); }, ), ); }, ), if (comment.isActive) ...[ IconButton( icon: const Icon(Icons.edit_outlined, size: 18, color: Colors.indigo), tooltip: "Edit", splashRadius: 18, onPressed: () { directoryController.editingCommentId.value = isEditing ? null : comment.id; }, ), IconButton( icon: const Icon(Icons.delete_outline, size: 18, color: Colors.red), tooltip: "Delete", splashRadius: 18, onPressed: () async { await Get.dialog( ConfirmDialog( title: "Delete Note", message: "Are you sure you want to delete this note?", confirmText: "Delete", confirmColor: Colors.red, icon: Icons.delete_forever, onConfirm: () async { await directoryController.deleteComment( comment.id, contactId); }, ), ); }, ), ], ], ), ], ), const SizedBox(height: 8), // 📝 Comment Content if (isEditing && quillController != null) CommentEditorCard( controller: quillController, onCancel: () => directoryController.editingCommentId.value = null, onSave: (ctrl) async { final delta = ctrl.document.toDelta(); final htmlOutput = _convertDeltaToHtml(delta); final updated = comment.copyWith(note: htmlOutput); await directoryController.updateComment(updated); await directoryController.fetchCommentsForContact(contactId); directoryController.editingCommentId.value = null; }, ) else html.Html( data: comment.note, style: { "body": html.Style( margin: html.Margins.zero, padding: html.HtmlPaddings.zero, fontSize: html.FontSize(14), color: Colors.black87, ), "p": html.Style( margin: html.Margins.only(bottom: 6), lineHeight: const html.LineHeight(1.4), ), "strong": html.Style( fontWeight: FontWeight.w700, color: Colors.black87, ), }, ), ], ), ); } Widget _iconInfoRow( IconData? icon, String label, String value, { VoidCallback? onTap, VoidCallback? onLongPress, }) { return Padding( padding: MySpacing.y(2), child: GestureDetector( onTap: onTap, onLongPress: onLongPress, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (icon != null) ...[ Icon(icon, size: 22, color: Colors.indigo), MySpacing.width(12), ] else const SizedBox(width: 34), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (label.isNotEmpty) MyText.bodySmall(label, fontWeight: 600, color: Colors.black87), if (label.isNotEmpty) MySpacing.height(2), MyText.bodyMedium(value, color: Colors.grey[800]), ], ), ), ], ), ), ); } Widget _infoCard(String title, List children) { return Card( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), elevation: 2, margin: MySpacing.bottom(12), child: Padding( padding: MySpacing.xy(16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.titleSmall(title, fontWeight: 700, color: Colors.indigo[700]), MySpacing.height(8), ...children, ], ), ), ); } } // Helper widget for Project label in AppBar class ProjectLabel extends StatelessWidget { final String? projectName; const ProjectLabel(this.projectName, {super.key}); @override Widget build(BuildContext context) { return Row( children: [ const Icon(Icons.work_outline, size: 14, color: Colors.grey), MySpacing.width(4), Expanded( child: MyText.bodySmall( projectName ?? 'Select Project', fontWeight: 600, overflow: TextOverflow.ellipsis, color: Colors.grey[700], ), ), ], ); } }