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_model.dart';
|
||||||
import 'package:marco/model/directory/contact_bucket_list_model.dart';
|
import 'package:marco/model/directory/contact_bucket_list_model.dart';
|
||||||
import 'package:marco/model/directory/directory_comment_model.dart';
|
import 'package:marco/model/directory/directory_comment_model.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
class DirectoryController extends GetxController {
|
class DirectoryController extends GetxController {
|
||||||
RxList<ContactModel> allContacts = <ContactModel>[].obs;
|
RxList<ContactModel> allContacts = <ContactModel>[].obs;
|
||||||
RxList<ContactModel> filteredContacts = <ContactModel>[].obs;
|
RxList<ContactModel> filteredContacts = <ContactModel>[].obs;
|
||||||
@ -16,8 +16,15 @@ class DirectoryController extends GetxController {
|
|||||||
RxList<ContactBucket> contactBuckets = <ContactBucket>[].obs;
|
RxList<ContactBucket> contactBuckets = <ContactBucket>[].obs;
|
||||||
RxString searchQuery = ''.obs;
|
RxString searchQuery = ''.obs;
|
||||||
RxBool showFabMenu = false.obs;
|
RxBool showFabMenu = false.obs;
|
||||||
RxMap<String, List<DirectoryComment>> contactCommentsMap =
|
final RxBool showFullEditorToolbar = false.obs;
|
||||||
<String, List<DirectoryComment>>{}.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
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
@ -25,41 +32,79 @@ class DirectoryController extends GetxController {
|
|||||||
fetchContacts();
|
fetchContacts();
|
||||||
fetchBuckets();
|
fetchBuckets();
|
||||||
}
|
}
|
||||||
|
// inside DirectoryController
|
||||||
|
|
||||||
void extractCategoriesFromContacts() {
|
Future<void> updateComment(DirectoryComment comment) async {
|
||||||
final uniqueCategories = <String, ContactCategory>{};
|
try {
|
||||||
|
logSafe(
|
||||||
|
"Attempting to update comment. id: ${comment.id}, contactId: ${comment.contactId}");
|
||||||
|
|
||||||
for (final contact in allContacts) {
|
final commentList = contactCommentsMap[comment.contactId];
|
||||||
final category = contact.contactCategory;
|
final oldComment =
|
||||||
if (category != null && !uniqueCategories.containsKey(category.id)) {
|
commentList?.firstWhereOrNull((c) => c.id == comment.id);
|
||||||
uniqueCategories[category.id] = category;
|
|
||||||
|
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 {
|
Future<void> fetchCommentsForContact(String contactId) async {
|
||||||
try {
|
try {
|
||||||
final data = await ApiService.getDirectoryComments(contactId);
|
final data = await ApiService.getDirectoryComments(contactId);
|
||||||
logSafe("Fetched comments for contact $contactId: $data");
|
logSafe("Fetched comments for contact $contactId: $data");
|
||||||
|
|
||||||
if (data != null ) {
|
final comments =
|
||||||
final comments = data.map((e) => DirectoryComment.fromJson(e)).toList();
|
data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? [];
|
||||||
contactCommentsMap[contactId] = comments;
|
|
||||||
} else {
|
if (!contactCommentsMap.containsKey(contactId)) {
|
||||||
contactCommentsMap[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 {
|
Future<void> fetchBuckets() async {
|
||||||
try {
|
try {
|
||||||
@ -86,9 +131,7 @@ class DirectoryController extends GetxController {
|
|||||||
if (response != null) {
|
if (response != null) {
|
||||||
final contacts = response.map((e) => ContactModel.fromJson(e)).toList();
|
final contacts = response.map((e) => ContactModel.fromJson(e)).toList();
|
||||||
allContacts.assignAll(contacts);
|
allContacts.assignAll(contacts);
|
||||||
|
|
||||||
extractCategoriesFromContacts();
|
extractCategoriesFromContacts();
|
||||||
|
|
||||||
applyFilters();
|
applyFilters();
|
||||||
} else {
|
} else {
|
||||||
allContacts.clear();
|
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() {
|
void applyFilters() {
|
||||||
final query = searchQuery.value.toLowerCase();
|
final query = searchQuery.value.toLowerCase();
|
||||||
|
|
||||||
filteredContacts.value = allContacts.where((contact) {
|
filteredContacts.value = allContacts.where((contact) {
|
||||||
// 1. Category filter
|
|
||||||
final categoryMatch = selectedCategories.isEmpty ||
|
final categoryMatch = selectedCategories.isEmpty ||
|
||||||
(contact.contactCategory != null &&
|
(contact.contactCategory != null &&
|
||||||
selectedCategories.contains(contact.contactCategory!.id));
|
selectedCategories.contains(contact.contactCategory!.id));
|
||||||
|
|
||||||
// 2. Bucket filter
|
|
||||||
final bucketMatch = selectedBuckets.isEmpty ||
|
final bucketMatch = selectedBuckets.isEmpty ||
|
||||||
contact.bucketIds.any((id) => selectedBuckets.contains(id));
|
contact.bucketIds.any((id) => selectedBuckets.contains(id));
|
||||||
|
|
||||||
// 3. Search filter: match name, organization, email, or tags
|
|
||||||
final nameMatch = contact.name.toLowerCase().contains(query);
|
final nameMatch = contact.name.toLowerCase().contains(query);
|
||||||
final orgMatch = contact.organization.toLowerCase().contains(query);
|
final orgMatch = contact.organization.toLowerCase().contains(query);
|
||||||
final emailMatch = contact.contactEmails
|
final emailMatch = contact.contactEmails
|
||||||
|
@ -41,4 +41,5 @@ class ApiEndpoints {
|
|||||||
static const String getDirectoryOrganization = "/directory/organization";
|
static const String getDirectoryOrganization = "/directory/organization";
|
||||||
static const String createContact = "/directory";
|
static const String createContact = "/directory";
|
||||||
static const String getDirectoryNotes = "/directory/notes";
|
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 ===
|
// === Dashboard Endpoints ===
|
||||||
|
|
||||||
static Future<List<dynamic>?> getDashboardAttendanceOverview(
|
static Future<List<dynamic>?> getDashboardAttendanceOverview(
|
||||||
@ -177,16 +220,67 @@ class ApiService {
|
|||||||
: null);
|
: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Directly calling the API
|
/// Directory calling the API
|
||||||
static Future<List<dynamic>?> getDirectoryComments(String contactId) async {
|
static Future<bool> updateContactComment(
|
||||||
final url = "${ApiEndpoints.getDirectoryNotes}/$contactId";
|
String commentId, String note, String contactId) async {
|
||||||
final response = await _getRequest(url);
|
final payload = {
|
||||||
final data = response != null
|
"id": commentId,
|
||||||
? _parseResponse(response, label: 'Directory Comments')
|
"contactId": contactId,
|
||||||
: null;
|
"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 {
|
static Future<bool> createContact(Map<String, dynamic> payload) async {
|
||||||
try {
|
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,
|
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 {
|
class CommentUser {
|
||||||
@ -98,4 +124,22 @@ class CommentUser {
|
|||||||
jobRoleName: json['jobRoleName'] ?? '',
|
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:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:intl/intl.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/project_controller.dart';
|
||||||
import 'package:marco/controller/directory/directory_controller.dart';
|
import 'package:marco/controller/directory/directory_controller.dart';
|
||||||
import 'package:marco/helpers/widgets/my_spacing.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/widgets/avatar.dart';
|
||||||
import 'package:marco/helpers/utils/launcher_utils.dart';
|
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:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart';
|
||||||
|
|
||||||
class ContactDetailScreen extends StatelessWidget {
|
class ContactDetailScreen extends StatefulWidget {
|
||||||
final ContactModel contact;
|
final ContactModel contact;
|
||||||
|
|
||||||
const ContactDetailScreen({super.key, required this.contact});
|
const ContactDetailScreen({super.key, required this.contact});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<ContactDetailScreen> createState() => _ContactDetailScreenState();
|
||||||
final directoryController = Get.find<DirectoryController>();
|
}
|
||||||
final projectController = Get.find<ProjectController>();
|
|
||||||
|
|
||||||
Future.microtask(() {
|
String _convertDeltaToHtml(dynamic delta) {
|
||||||
if (!directoryController.contactCommentsMap.containsKey(contact.id)) {
|
final buffer = StringBuffer();
|
||||||
directoryController.fetchCommentsForContact(contact.id);
|
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(
|
return DefaultTabController(
|
||||||
length: 2,
|
length: 2,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: const Color(0xFFF5F5F5),
|
backgroundColor: const Color(0xFFF5F5F5),
|
||||||
appBar: _buildMainAppBar(projectController),
|
appBar: _buildMainAppBar(),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -40,8 +105,8 @@ class ContactDetailScreen extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
children: [
|
children: [
|
||||||
_buildDetailsTab(directoryController, projectController),
|
_buildDetailsTab(),
|
||||||
_buildCommentsTab(directoryController),
|
_buildCommentsTab(context),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -52,7 +117,7 @@ class ContactDetailScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
PreferredSizeWidget _buildMainAppBar(ProjectController projectController) {
|
PreferredSizeWidget _buildMainAppBar() {
|
||||||
return AppBar(
|
return AppBar(
|
||||||
backgroundColor: const Color(0xFFF5F5F5),
|
backgroundColor: const Color(0xFFF5F5F5),
|
||||||
elevation: 0.2,
|
elevation: 0.2,
|
||||||
@ -74,11 +139,8 @@ class ContactDetailScreen extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
MyText.titleLarge(
|
MyText.titleLarge('Contact Profile',
|
||||||
'Contact Profile',
|
fontWeight: 700, color: Colors.black),
|
||||||
fontWeight: 700,
|
|
||||||
color: Colors.black,
|
|
||||||
),
|
|
||||||
MySpacing.height(2),
|
MySpacing.height(2),
|
||||||
GetBuilder<ProjectController>(
|
GetBuilder<ProjectController>(
|
||||||
builder: (projectController) {
|
builder: (projectController) {
|
||||||
@ -120,9 +182,9 @@ class ContactDetailScreen extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Avatar(
|
Avatar(
|
||||||
firstName: contact.name.split(" ").first,
|
firstName: widget.contact.name.split(" ").first,
|
||||||
lastName: contact.name.split(" ").length > 1
|
lastName: widget.contact.name.split(" ").length > 1
|
||||||
? contact.name.split(" ").last
|
? widget.contact.name.split(" ").last
|
||||||
: "",
|
: "",
|
||||||
size: 35,
|
size: 35,
|
||||||
backgroundColor: Colors.indigo,
|
backgroundColor: Colors.indigo,
|
||||||
@ -131,10 +193,10 @@ class ContactDetailScreen extends StatelessWidget {
|
|||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
MyText.titleSmall(contact.name,
|
MyText.titleSmall(widget.contact.name,
|
||||||
fontWeight: 600, color: Colors.black),
|
fontWeight: 600, color: Colors.black),
|
||||||
MySpacing.height(2),
|
MySpacing.height(2),
|
||||||
MyText.bodySmall(contact.organization,
|
MyText.bodySmall(widget.contact.organization,
|
||||||
fontWeight: 500, color: Colors.grey[700]),
|
fontWeight: 500, color: Colors.grey[700]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -161,28 +223,28 @@ class ContactDetailScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDetailsTab(DirectoryController directoryController,
|
Widget _buildDetailsTab() {
|
||||||
ProjectController projectController) {
|
final email = widget.contact.contactEmails.isNotEmpty
|
||||||
final email = contact.contactEmails.isNotEmpty
|
? widget.contact.contactEmails.first.emailAddress
|
||||||
? contact.contactEmails.first.emailAddress
|
|
||||||
: "-";
|
: "-";
|
||||||
|
|
||||||
final phone = contact.contactPhones.isNotEmpty
|
final phone = widget.contact.contactPhones.isNotEmpty
|
||||||
? contact.contactPhones.first.phoneNumber
|
? 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 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
|
.map((id) => directoryController.contactBuckets
|
||||||
.firstWhereOrNull((b) => b.id == id)
|
.firstWhereOrNull((b) => b.id == id)
|
||||||
?.name)
|
?.name)
|
||||||
.whereType<String>()
|
.whereType<String>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
final projectNames = contact.projectIds
|
final projectNames = widget.contact.projectIds
|
||||||
?.map((id) => projectController.projects
|
?.map((id) => projectController.projects
|
||||||
.firstWhereOrNull((p) => p.id == id)
|
.firstWhereOrNull((p) => p.id == id)
|
||||||
?.name)
|
?.name)
|
||||||
@ -190,7 +252,7 @@ class ContactDetailScreen extends StatelessWidget {
|
|||||||
.join(", ") ??
|
.join(", ") ??
|
||||||
"-";
|
"-";
|
||||||
|
|
||||||
final category = contact.contactCategory?.name ?? "-";
|
final category = widget.contact.contactCategory?.name ?? "-";
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: MySpacing.xy(8, 8),
|
padding: MySpacing.xy(8, 8),
|
||||||
@ -207,10 +269,11 @@ class ContactDetailScreen extends StatelessWidget {
|
|||||||
onLongPress: () =>
|
onLongPress: () =>
|
||||||
LauncherUtils.copyToClipboard(phone, typeLabel: "Phone")),
|
LauncherUtils.copyToClipboard(phone, typeLabel: "Phone")),
|
||||||
_iconInfoRow(Icons.calendar_today, "Created", formattedDate),
|
_iconInfoRow(Icons.calendar_today, "Created", formattedDate),
|
||||||
_iconInfoRow(Icons.location_on, "Address", contact.address),
|
_iconInfoRow(Icons.location_on, "Address", widget.contact.address),
|
||||||
]),
|
]),
|
||||||
_infoCard("Organization", [
|
_infoCard("Organization", [
|
||||||
_iconInfoRow(Icons.business, "Organization", contact.organization),
|
_iconInfoRow(
|
||||||
|
Icons.business, "Organization", widget.contact.organization),
|
||||||
_iconInfoRow(Icons.category, "Category", category),
|
_iconInfoRow(Icons.category, "Category", category),
|
||||||
]),
|
]),
|
||||||
_infoCard("Meta Info", [
|
_infoCard("Meta Info", [
|
||||||
@ -224,7 +287,7 @@ class ContactDetailScreen extends StatelessWidget {
|
|||||||
Align(
|
Align(
|
||||||
alignment: Alignment.topLeft,
|
alignment: Alignment.topLeft,
|
||||||
child: MyText.bodyMedium(
|
child: MyText.bodyMedium(
|
||||||
contact.description,
|
widget.contact.description,
|
||||||
color: Colors.grey[800],
|
color: Colors.grey[800],
|
||||||
maxLines: 10,
|
maxLines: 10,
|
||||||
textAlign: TextAlign.left,
|
textAlign: TextAlign.left,
|
||||||
@ -236,17 +299,21 @@ class ContactDetailScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCommentsTab(DirectoryController directoryController) {
|
Widget _buildCommentsTab(BuildContext context) {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
final comments = directoryController.contactCommentsMap[contact.id];
|
if (!directoryController.contactCommentsMap
|
||||||
|
.containsKey(widget.contact.id)) {
|
||||||
if (comments == null) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final comments =
|
||||||
|
directoryController.getCommentsForContact(widget.contact.id);
|
||||||
|
final editingId = directoryController.editingCommentId.value;
|
||||||
|
|
||||||
if (comments.isEmpty) {
|
if (comments.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: MyText.bodyLarge("No comments yet.", color: Colors.grey));
|
child: MyText.bodyLarge("No comments yet.", color: Colors.grey),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
@ -255,10 +322,22 @@ class ContactDetailScreen extends StatelessWidget {
|
|||||||
separatorBuilder: (_, __) => MySpacing.height(12),
|
separatorBuilder: (_, __) => MySpacing.height(12),
|
||||||
itemBuilder: (_, index) {
|
itemBuilder: (_, index) {
|
||||||
final comment = comments[index];
|
final comment = comments[index];
|
||||||
|
final isEditing = editingId == comment.id;
|
||||||
|
|
||||||
final initials = comment.createdBy.firstName.isNotEmpty
|
final initials = comment.createdBy.firstName.isNotEmpty
|
||||||
? comment.createdBy.firstName[0].toUpperCase()
|
? 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(
|
return Container(
|
||||||
padding: MySpacing.xy(14, 12),
|
padding: MySpacing.xy(14, 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -296,40 +375,55 @@ class ContactDetailScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.more_vert,
|
icon: Icon(isEditing ? Icons.close : Icons.edit,
|
||||||
size: 20, color: Colors.grey),
|
size: 20, color: Colors.grey[700]),
|
||||||
onPressed: () {},
|
onPressed: () {
|
||||||
padding: EdgeInsets.zero,
|
directoryController.editingCommentId.value =
|
||||||
constraints: const BoxConstraints(),
|
isEditing ? null : comment.id;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
MySpacing.height(10),
|
MySpacing.height(10),
|
||||||
Html(
|
if (isEditing && quillController != null)
|
||||||
data: comment.note,
|
CommentEditorCard(
|
||||||
style: {
|
controller: quillController,
|
||||||
"body": Style(
|
onCancel: () {
|
||||||
margin: Margins.all(0),
|
directoryController.editingCommentId.value = null;
|
||||||
padding: HtmlPaddings.all(0),
|
},
|
||||||
fontSize: FontSize.medium,
|
onSave: (controller) async {
|
||||||
color: Colors.black87,
|
final delta = controller.document.toDelta();
|
||||||
),
|
final htmlOutput = _convertDeltaToHtml(delta);
|
||||||
"pre": Style(
|
final updated = comment.copyWith(note: htmlOutput);
|
||||||
padding: HtmlPaddings.all(8),
|
await directoryController.updateComment(updated);
|
||||||
fontSize: FontSize.small,
|
directoryController.editingCommentId.value = null;
|
||||||
fontFamily: 'monospace',
|
})
|
||||||
backgroundColor: const Color(0xFFF1F1F1),
|
else
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
html.Html(
|
||||||
),
|
data: comment.note,
|
||||||
"h3": Style(
|
style: {
|
||||||
fontSize: FontSize.large,
|
"body": html.Style(
|
||||||
fontWeight: FontWeight.bold,
|
margin: html.Margins.all(0),
|
||||||
color: Colors.indigo[700],
|
padding: html.HtmlPaddings.all(0),
|
||||||
),
|
fontSize: html.FontSize.medium,
|
||||||
"strong": Style(fontWeight: FontWeight.w700),
|
color: Colors.black87,
|
||||||
"p": Style(margin: Margins.only(bottom: 8)),
|
),
|
||||||
},
|
"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
|
intl: ^0.19.0
|
||||||
syncfusion_flutter_core: ^28.1.33
|
syncfusion_flutter_core: ^28.1.33
|
||||||
syncfusion_flutter_sliders: ^28.1.33
|
syncfusion_flutter_sliders: ^28.1.33
|
||||||
file_picker: ^8.1.5
|
file_picker: ^9.2.3
|
||||||
timelines_plus: ^1.0.4
|
timelines_plus: ^1.0.4
|
||||||
syncfusion_flutter_charts: ^28.1.33
|
syncfusion_flutter_charts: ^28.1.33
|
||||||
appflowy_board: ^0.1.2
|
appflowy_board: ^0.1.2
|
||||||
@ -74,6 +74,9 @@ dependencies:
|
|||||||
font_awesome_flutter: ^10.8.0
|
font_awesome_flutter: ^10.8.0
|
||||||
flutter_html: ^3.0.0
|
flutter_html: ^3.0.0
|
||||||
tab_indicator_styler: ^2.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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
Loading…
x
Reference in New Issue
Block a user