feat(directory): implement comment editing functionality and enhance comment model
This commit is contained in:
parent
83ad10ffb4
commit
be71544ae4
@ -4,7 +4,7 @@ import 'package:marco/helpers/services/app_logger.dart';
|
||||
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<ContactModel> allContacts = <ContactModel>[].obs;
|
||||
RxList<ContactModel> filteredContacts = <ContactModel>[].obs;
|
||||
@ -16,8 +16,15 @@ class DirectoryController extends GetxController {
|
||||
RxList<ContactBucket> contactBuckets = <ContactBucket>[].obs;
|
||||
RxString searchQuery = ''.obs;
|
||||
RxBool showFabMenu = false.obs;
|
||||
RxMap<String, List<DirectoryComment>> contactCommentsMap =
|
||||
<String, List<DirectoryComment>>{}.obs;
|
||||
final RxBool showFullEditorToolbar = false.obs;
|
||||
final RxBool isEditorFocused = false.obs;
|
||||
|
||||
final Map<String, RxList<DirectoryComment>> contactCommentsMap = {};
|
||||
RxList<DirectoryComment> getCommentsForContact(String contactId) {
|
||||
return contactCommentsMap[contactId] ?? <DirectoryComment>[].obs;
|
||||
}
|
||||
|
||||
final editingCommentId = Rxn<String>();
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
@ -25,41 +32,79 @@ class DirectoryController extends GetxController {
|
||||
fetchContacts();
|
||||
fetchBuckets();
|
||||
}
|
||||
// inside DirectoryController
|
||||
|
||||
void extractCategoriesFromContacts() {
|
||||
final uniqueCategories = <String, ContactCategory>{};
|
||||
Future<void> updateComment(DirectoryComment comment) async {
|
||||
try {
|
||||
logSafe(
|
||||
"Attempting to update comment. id: ${comment.id}, contactId: ${comment.contactId}");
|
||||
|
||||
for (final contact in allContacts) {
|
||||
final category = contact.contactCategory;
|
||||
if (category != null && !uniqueCategories.containsKey(category.id)) {
|
||||
uniqueCategories[category.id] = category;
|
||||
final commentList = contactCommentsMap[comment.contactId];
|
||||
final oldComment =
|
||||
commentList?.firstWhereOrNull((c) => c.id == comment.id);
|
||||
|
||||
if (oldComment == null) {
|
||||
logSafe("Old comment not found. id: ${comment.id}");
|
||||
} else {
|
||||
logSafe("Old comment note: ${oldComment.note}");
|
||||
logSafe("New comment note: ${comment.note}");
|
||||
}
|
||||
}
|
||||
|
||||
contactCategories.value = uniqueCategories.values.toList();
|
||||
if (oldComment != null && oldComment.note.trim() == comment.note.trim()) {
|
||||
logSafe("No changes detected in comment. id: ${comment.id}");
|
||||
return;
|
||||
}
|
||||
|
||||
final success = await ApiService.updateContactComment(
|
||||
comment.id,
|
||||
comment.note,
|
||||
comment.contactId,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
logSafe("Comment updated successfully. id: ${comment.id}");
|
||||
await fetchCommentsForContact(comment.contactId);
|
||||
} else {
|
||||
logSafe("Failed to update comment via API. id: ${comment.id}");
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to update comment.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
logSafe("Update comment failed: ${e.toString()}");
|
||||
logSafe("StackTrace: ${stackTrace.toString()}");
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to update comment.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchCommentsForContact(String contactId) async {
|
||||
try {
|
||||
final data = await ApiService.getDirectoryComments(contactId);
|
||||
logSafe("Fetched comments for contact $contactId: $data");
|
||||
try {
|
||||
final data = await ApiService.getDirectoryComments(contactId);
|
||||
logSafe("Fetched comments for contact $contactId: $data");
|
||||
|
||||
if (data != null ) {
|
||||
final comments = data.map((e) => DirectoryComment.fromJson(e)).toList();
|
||||
contactCommentsMap[contactId] = comments;
|
||||
} else {
|
||||
contactCommentsMap[contactId] = [];
|
||||
final comments =
|
||||
data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? [];
|
||||
|
||||
if (!contactCommentsMap.containsKey(contactId)) {
|
||||
contactCommentsMap[contactId] = <DirectoryComment>[].obs;
|
||||
}
|
||||
|
||||
contactCommentsMap[contactId]!.assignAll(comments);
|
||||
contactCommentsMap[contactId]?.refresh();
|
||||
} catch (e) {
|
||||
logSafe("Error fetching comments for contact $contactId: $e",
|
||||
level: LogLevel.error);
|
||||
|
||||
contactCommentsMap[contactId] ??= <DirectoryComment>[].obs;
|
||||
contactCommentsMap[contactId]!.clear();
|
||||
}
|
||||
|
||||
contactCommentsMap.refresh();
|
||||
} catch (e) {
|
||||
logSafe("Error fetching comments for contact $contactId: $e",
|
||||
level: LogLevel.error);
|
||||
contactCommentsMap[contactId] = [];
|
||||
contactCommentsMap.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> fetchBuckets() async {
|
||||
try {
|
||||
@ -86,9 +131,7 @@ class DirectoryController extends GetxController {
|
||||
if (response != null) {
|
||||
final contacts = response.map((e) => ContactModel.fromJson(e)).toList();
|
||||
allContacts.assignAll(contacts);
|
||||
|
||||
extractCategoriesFromContacts();
|
||||
|
||||
applyFilters();
|
||||
} else {
|
||||
allContacts.clear();
|
||||
@ -101,20 +144,30 @@ class DirectoryController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
void extractCategoriesFromContacts() {
|
||||
final uniqueCategories = <String, ContactCategory>{};
|
||||
|
||||
for (final contact in allContacts) {
|
||||
final category = contact.contactCategory;
|
||||
if (category != null && !uniqueCategories.containsKey(category.id)) {
|
||||
uniqueCategories[category.id] = category;
|
||||
}
|
||||
}
|
||||
|
||||
contactCategories.value = uniqueCategories.values.toList();
|
||||
}
|
||||
|
||||
void applyFilters() {
|
||||
final query = searchQuery.value.toLowerCase();
|
||||
|
||||
filteredContacts.value = allContacts.where((contact) {
|
||||
// 1. Category filter
|
||||
final categoryMatch = selectedCategories.isEmpty ||
|
||||
(contact.contactCategory != null &&
|
||||
selectedCategories.contains(contact.contactCategory!.id));
|
||||
|
||||
// 2. Bucket filter
|
||||
final bucketMatch = selectedBuckets.isEmpty ||
|
||||
contact.bucketIds.any((id) => selectedBuckets.contains(id));
|
||||
|
||||
// 3. Search filter: match name, organization, email, or tags
|
||||
final nameMatch = contact.name.toLowerCase().contains(query);
|
||||
final orgMatch = contact.organization.toLowerCase().contains(query);
|
||||
final emailMatch = contact.contactEmails
|
||||
|
@ -41,4 +41,5 @@ class ApiEndpoints {
|
||||
static const String getDirectoryOrganization = "/directory/organization";
|
||||
static const String createContact = "/directory";
|
||||
static const String getDirectoryNotes = "/directory/notes";
|
||||
static const String updateDirectoryNotes = "/directory/note";
|
||||
}
|
||||
|
@ -162,6 +162,49 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<http.Response?> _putRequest(
|
||||
String endpoint,
|
||||
dynamic body, {
|
||||
Map<String, String>? additionalHeaders,
|
||||
Duration customTimeout = timeout,
|
||||
bool hasRetried = false,
|
||||
}) async {
|
||||
String? token = await _getToken();
|
||||
if (token == null) return null;
|
||||
|
||||
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint");
|
||||
logSafe(
|
||||
"PUT $uri\nHeaders: ${_headers(token)}\nBody: $body",
|
||||
);
|
||||
final headers = {
|
||||
..._headers(token),
|
||||
if (additionalHeaders != null) ...additionalHeaders,
|
||||
};
|
||||
|
||||
logSafe("PUT $uri\nHeaders: $headers\nBody: $body", sensitive: true);
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.put(uri, headers: headers, body: jsonEncode(body))
|
||||
.timeout(customTimeout);
|
||||
|
||||
if (response.statusCode == 401 && !hasRetried) {
|
||||
logSafe("Unauthorized PUT. Attempting token refresh...");
|
||||
if (await AuthService.refreshToken()) {
|
||||
return await _putRequest(endpoint, body,
|
||||
additionalHeaders: additionalHeaders,
|
||||
customTimeout: customTimeout,
|
||||
hasRetried: true);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logSafe("HTTP PUT Exception: $e", level: LogLevel.error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// === Dashboard Endpoints ===
|
||||
|
||||
static Future<List<dynamic>?> getDashboardAttendanceOverview(
|
||||
@ -177,16 +220,67 @@ class ApiService {
|
||||
: null);
|
||||
}
|
||||
|
||||
/// Directly calling the API
|
||||
static Future<List<dynamic>?> getDirectoryComments(String contactId) async {
|
||||
final url = "${ApiEndpoints.getDirectoryNotes}/$contactId";
|
||||
final response = await _getRequest(url);
|
||||
final data = response != null
|
||||
? _parseResponse(response, label: 'Directory Comments')
|
||||
: null;
|
||||
/// Directory calling the API
|
||||
static Future<bool> updateContactComment(
|
||||
String commentId, String note, String contactId) async {
|
||||
final payload = {
|
||||
"id": commentId,
|
||||
"contactId": contactId,
|
||||
"note": note,
|
||||
};
|
||||
|
||||
return data is List ? data : null;
|
||||
}
|
||||
final endpoint = "${ApiEndpoints.updateDirectoryNotes}/$commentId";
|
||||
|
||||
final headers = {
|
||||
"comment-id": commentId,
|
||||
};
|
||||
|
||||
logSafe("Updating comment with payload: $payload");
|
||||
logSafe("Headers for update comment: $headers");
|
||||
logSafe("Sending update comment request to $endpoint");
|
||||
|
||||
try {
|
||||
final response = await _putRequest(
|
||||
endpoint,
|
||||
payload,
|
||||
additionalHeaders: headers,
|
||||
);
|
||||
|
||||
if (response == null) {
|
||||
logSafe("Update comment failed: null response", level: LogLevel.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
logSafe("Update comment response status: ${response.statusCode}");
|
||||
logSafe("Update comment response body: ${response.body}");
|
||||
|
||||
final json = jsonDecode(response.body);
|
||||
|
||||
if (json['success'] == true) {
|
||||
logSafe("Comment updated successfully. commentId: $commentId");
|
||||
return true;
|
||||
} else {
|
||||
logSafe("Failed to update comment: ${json['message']}",
|
||||
level: LogLevel.warning);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
logSafe("Exception during updateComment API: ${e.toString()}",
|
||||
level: LogLevel.error);
|
||||
logSafe("StackTrace: ${stack.toString()}", level: LogLevel.debug);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<List<dynamic>?> getDirectoryComments(String contactId) async {
|
||||
final url = "${ApiEndpoints.getDirectoryNotes}/$contactId";
|
||||
final response = await _getRequest(url);
|
||||
final data = response != null
|
||||
? _parseResponse(response, label: 'Directory Comments')
|
||||
: null;
|
||||
|
||||
return data is List ? data : null;
|
||||
}
|
||||
|
||||
static Future<bool> createContact(Map<String, dynamic> payload) async {
|
||||
try {
|
||||
|
120
lib/helpers/widgets/Directory/comment_editor_card.dart
Normal file
120
lib/helpers/widgets/Directory/comment_editor_card.dart
Normal file
@ -0,0 +1,120 @@
|
||||
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;
|
||||
final VoidCallback onCancel;
|
||||
final Future<void> Function(quill.QuillController controller) onSave;
|
||||
|
||||
const CommentEditorCard({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onCancel,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
@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),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 120,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: const Color(0xFFFDFDFD),
|
||||
),
|
||||
child: quill.QuillEditor.basic(
|
||||
controller: controller,
|
||||
configurations: const quill.QuillEditorConfigurations(
|
||||
autoFocus: true,
|
||||
expands: false,
|
||||
scrollable: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: onCancel,
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
label: const Text("Cancel"),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => onSave(controller),
|
||||
icon: const Icon(Icons.save, size: 18),
|
||||
label: const Text("Save"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.indigo,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -69,6 +69,32 @@ class DirectoryComment {
|
||||
isActive: json['isActive'] ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
DirectoryComment copyWith({
|
||||
String? id,
|
||||
String? note,
|
||||
String? contactName,
|
||||
String? organizationName,
|
||||
DateTime? createdAt,
|
||||
CommentUser? createdBy,
|
||||
DateTime? updatedAt,
|
||||
CommentUser? updatedBy,
|
||||
String? contactId,
|
||||
bool? isActive,
|
||||
}) {
|
||||
return DirectoryComment(
|
||||
id: id ?? this.id,
|
||||
note: note ?? this.note,
|
||||
contactName: contactName ?? this.contactName,
|
||||
organizationName: organizationName ?? this.organizationName,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
createdBy: createdBy ?? this.createdBy,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
updatedBy: updatedBy ?? this.updatedBy,
|
||||
contactId: contactId ?? this.contactId,
|
||||
isActive: isActive ?? this.isActive,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CommentUser {
|
||||
@ -98,4 +124,22 @@ class CommentUser {
|
||||
jobRoleName: json['jobRoleName'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
CommentUser copyWith({
|
||||
String? id,
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? photo,
|
||||
String? jobRoleId,
|
||||
String? jobRoleName,
|
||||
}) {
|
||||
return CommentUser(
|
||||
id: id ?? this.id,
|
||||
firstName: firstName ?? this.firstName,
|
||||
lastName: lastName ?? this.lastName,
|
||||
photo: photo ?? this.photo,
|
||||
jobRoleId: jobRoleId ?? this.jobRoleId,
|
||||
jobRoleName: jobRoleName ?? this.jobRoleName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:flutter_html/flutter_html.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';
|
||||
@ -10,28 +11,92 @@ 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';
|
||||
|
||||
class ContactDetailScreen extends StatelessWidget {
|
||||
class ContactDetailScreen extends StatefulWidget {
|
||||
final ContactModel contact;
|
||||
|
||||
const ContactDetailScreen({super.key, required this.contact});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final directoryController = Get.find<DirectoryController>();
|
||||
final projectController = Get.find<ProjectController>();
|
||||
State<ContactDetailScreen> createState() => _ContactDetailScreenState();
|
||||
}
|
||||
|
||||
Future.microtask(() {
|
||||
if (!directoryController.contactCommentsMap.containsKey(contact.id)) {
|
||||
directoryController.fetchCommentsForContact(contact.id);
|
||||
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 list
|
||||
if (isListItem && !inList) {
|
||||
buffer.write('<ul>');
|
||||
inList = true;
|
||||
}
|
||||
|
||||
// Close list if we are not in list mode anymore
|
||||
if (!isListItem && inList) {
|
||||
buffer.write('</ul>');
|
||||
inList = false;
|
||||
}
|
||||
|
||||
if (isListItem) buffer.write('<li>');
|
||||
|
||||
// Apply inline styles
|
||||
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']}">');
|
||||
|
||||
buffer.write(data.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();
|
||||
}
|
||||
|
||||
|
||||
class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
||||
late final DirectoryController directoryController;
|
||||
late final ProjectController projectController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
directoryController = Get.find<DirectoryController>();
|
||||
projectController = Get.find<ProjectController>();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!directoryController.contactCommentsMap
|
||||
.containsKey(widget.contact.id)) {
|
||||
directoryController.fetchCommentsForContact(widget.contact.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: _buildMainAppBar(projectController),
|
||||
appBar: _buildMainAppBar(),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -40,8 +105,8 @@ class ContactDetailScreen extends StatelessWidget {
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
_buildDetailsTab(directoryController, projectController),
|
||||
_buildCommentsTab(directoryController),
|
||||
_buildDetailsTab(),
|
||||
_buildCommentsTab(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -52,7 +117,7 @@ class ContactDetailScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildMainAppBar(ProjectController projectController) {
|
||||
PreferredSizeWidget _buildMainAppBar() {
|
||||
return AppBar(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
elevation: 0.2,
|
||||
@ -74,11 +139,8 @@ class ContactDetailScreen extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MyText.titleLarge(
|
||||
'Contact Profile',
|
||||
fontWeight: 700,
|
||||
color: Colors.black,
|
||||
),
|
||||
MyText.titleLarge('Contact Profile',
|
||||
fontWeight: 700, color: Colors.black),
|
||||
MySpacing.height(2),
|
||||
GetBuilder<ProjectController>(
|
||||
builder: (projectController) {
|
||||
@ -120,9 +182,9 @@ class ContactDetailScreen extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Avatar(
|
||||
firstName: contact.name.split(" ").first,
|
||||
lastName: contact.name.split(" ").length > 1
|
||||
? contact.name.split(" ").last
|
||||
firstName: widget.contact.name.split(" ").first,
|
||||
lastName: widget.contact.name.split(" ").length > 1
|
||||
? widget.contact.name.split(" ").last
|
||||
: "",
|
||||
size: 35,
|
||||
backgroundColor: Colors.indigo,
|
||||
@ -131,10 +193,10 @@ class ContactDetailScreen extends StatelessWidget {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.titleSmall(contact.name,
|
||||
MyText.titleSmall(widget.contact.name,
|
||||
fontWeight: 600, color: Colors.black),
|
||||
MySpacing.height(2),
|
||||
MyText.bodySmall(contact.organization,
|
||||
MyText.bodySmall(widget.contact.organization,
|
||||
fontWeight: 500, color: Colors.grey[700]),
|
||||
],
|
||||
),
|
||||
@ -161,28 +223,28 @@ class ContactDetailScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailsTab(DirectoryController directoryController,
|
||||
ProjectController projectController) {
|
||||
final email = contact.contactEmails.isNotEmpty
|
||||
? contact.contactEmails.first.emailAddress
|
||||
Widget _buildDetailsTab() {
|
||||
final email = widget.contact.contactEmails.isNotEmpty
|
||||
? widget.contact.contactEmails.first.emailAddress
|
||||
: "-";
|
||||
|
||||
final phone = contact.contactPhones.isNotEmpty
|
||||
? contact.contactPhones.first.phoneNumber
|
||||
final phone = widget.contact.contactPhones.isNotEmpty
|
||||
? widget.contact.contactPhones.first.phoneNumber
|
||||
: "-";
|
||||
|
||||
final createdDate = DateTime.now();
|
||||
final createdDate =
|
||||
DateTime.now(); // TODO: Replace with actual creation date if available
|
||||
final formattedDate = DateFormat('MMMM dd, yyyy').format(createdDate);
|
||||
final tags = contact.tags.map((e) => e.name).join(", ");
|
||||
final tags = widget.contact.tags.map((e) => e.name).join(", ");
|
||||
|
||||
final bucketNames = contact.bucketIds
|
||||
final bucketNames = widget.contact.bucketIds
|
||||
.map((id) => directoryController.contactBuckets
|
||||
.firstWhereOrNull((b) => b.id == id)
|
||||
?.name)
|
||||
.whereType<String>()
|
||||
.join(", ");
|
||||
|
||||
final projectNames = contact.projectIds
|
||||
final projectNames = widget.contact.projectIds
|
||||
?.map((id) => projectController.projects
|
||||
.firstWhereOrNull((p) => p.id == id)
|
||||
?.name)
|
||||
@ -190,7 +252,7 @@ class ContactDetailScreen extends StatelessWidget {
|
||||
.join(", ") ??
|
||||
"-";
|
||||
|
||||
final category = contact.contactCategory?.name ?? "-";
|
||||
final category = widget.contact.contactCategory?.name ?? "-";
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: MySpacing.xy(8, 8),
|
||||
@ -207,10 +269,11 @@ class ContactDetailScreen extends StatelessWidget {
|
||||
onLongPress: () =>
|
||||
LauncherUtils.copyToClipboard(phone, typeLabel: "Phone")),
|
||||
_iconInfoRow(Icons.calendar_today, "Created", formattedDate),
|
||||
_iconInfoRow(Icons.location_on, "Address", contact.address),
|
||||
_iconInfoRow(Icons.location_on, "Address", widget.contact.address),
|
||||
]),
|
||||
_infoCard("Organization", [
|
||||
_iconInfoRow(Icons.business, "Organization", contact.organization),
|
||||
_iconInfoRow(
|
||||
Icons.business, "Organization", widget.contact.organization),
|
||||
_iconInfoRow(Icons.category, "Category", category),
|
||||
]),
|
||||
_infoCard("Meta Info", [
|
||||
@ -224,7 +287,7 @@ class ContactDetailScreen extends StatelessWidget {
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: MyText.bodyMedium(
|
||||
contact.description,
|
||||
widget.contact.description,
|
||||
color: Colors.grey[800],
|
||||
maxLines: 10,
|
||||
textAlign: TextAlign.left,
|
||||
@ -236,17 +299,21 @@ class ContactDetailScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommentsTab(DirectoryController directoryController) {
|
||||
Widget _buildCommentsTab(BuildContext context) {
|
||||
return Obx(() {
|
||||
final comments = directoryController.contactCommentsMap[contact.id];
|
||||
|
||||
if (comments == null) {
|
||||
if (!directoryController.contactCommentsMap
|
||||
.containsKey(widget.contact.id)) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final comments =
|
||||
directoryController.getCommentsForContact(widget.contact.id);
|
||||
final editingId = directoryController.editingCommentId.value;
|
||||
|
||||
if (comments.isEmpty) {
|
||||
return Center(
|
||||
child: MyText.bodyLarge("No comments yet.", color: Colors.grey));
|
||||
child: MyText.bodyLarge("No comments yet.", color: Colors.grey),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
@ -255,10 +322,22 @@ class ContactDetailScreen extends StatelessWidget {
|
||||
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;
|
||||
|
||||
return Container(
|
||||
padding: MySpacing.xy(14, 12),
|
||||
decoration: BoxDecoration(
|
||||
@ -296,40 +375,55 @@ class ContactDetailScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert,
|
||||
size: 20, color: Colors.grey),
|
||||
onPressed: () {},
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: Icon(isEditing ? Icons.close : Icons.edit,
|
||||
size: 20, color: Colors.grey[700]),
|
||||
onPressed: () {
|
||||
directoryController.editingCommentId.value =
|
||||
isEditing ? null : comment.id;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
MySpacing.height(10),
|
||||
Html(
|
||||
data: comment.note,
|
||||
style: {
|
||||
"body": Style(
|
||||
margin: Margins.all(0),
|
||||
padding: HtmlPaddings.all(0),
|
||||
fontSize: FontSize.medium,
|
||||
color: Colors.black87,
|
||||
),
|
||||
"pre": Style(
|
||||
padding: HtmlPaddings.all(8),
|
||||
fontSize: FontSize.small,
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: const Color(0xFFF1F1F1),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
"h3": Style(
|
||||
fontSize: FontSize.large,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.indigo[700],
|
||||
),
|
||||
"strong": Style(fontWeight: FontWeight.w700),
|
||||
"p": Style(margin: Margins.only(bottom: 8)),
|
||||
},
|
||||
),
|
||||
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)),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
@ -52,7 +52,7 @@ dependencies:
|
||||
intl: ^0.19.0
|
||||
syncfusion_flutter_core: ^28.1.33
|
||||
syncfusion_flutter_sliders: ^28.1.33
|
||||
file_picker: ^8.1.5
|
||||
file_picker: ^9.2.3
|
||||
timelines_plus: ^1.0.4
|
||||
syncfusion_flutter_charts: ^28.1.33
|
||||
appflowy_board: ^0.1.2
|
||||
@ -74,6 +74,9 @@ dependencies:
|
||||
font_awesome_flutter: ^10.8.0
|
||||
flutter_html: ^3.0.0
|
||||
tab_indicator_styler: ^2.0.0
|
||||
html_editor_enhanced: ^2.7.0
|
||||
flutter_quill_delta_from_html: ^1.5.2
|
||||
quill_delta: ^3.0.0-nullsafety.2
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
Loading…
x
Reference in New Issue
Block a user