feat(contact): implement contact editing functionality and update API integration

This commit is contained in:
Vaibhav Surve 2025-07-07 15:34:07 +05:30
parent 5fb18a13d2
commit 445cd75e03
5 changed files with 263 additions and 150 deletions

View File

@ -82,6 +82,7 @@ class AddContactController extends GetxController {
}
Future<void> submitContact({
String? id,
required String name,
required String organization,
required List<Map<String, String>> emails,
@ -96,10 +97,13 @@ class AddContactController extends GetxController {
final tagObjects = enteredTags.map((tagName) {
final tagId = tagsMap[tagName];
return tagId != null ? {"id": tagId, "name": tagName} : {"name": tagName};
return tagId != null
? {"id": tagId, "name": tagName}
: {"name": tagName};
}).toList();
final body = {
if (id != null) "id": id,
"name": name,
"organization": organization,
"contactCategoryId": categoryId,
@ -112,27 +116,30 @@ class AddContactController extends GetxController {
"description": description,
};
logSafe("Submitting contact", sensitive: true);
logSafe("${id != null ? 'Updating' : 'Creating'} contact");
final response = id != null
? await ApiService.updateContact(id, body)
: await ApiService.createContact(body);
final response = await ApiService.createContact(body);
if (response == true) {
logSafe("Contact creation succeeded");
Get.back(result: true);
showAppSnackbar(
title: "Success",
message: "Contact created successfully",
message: id != null
? "Contact updated successfully"
: "Contact created successfully",
type: SnackbarType.success,
);
} else {
logSafe("Contact creation failed", level: LogLevel.error);
showAppSnackbar(
title: "Error",
message: "Failed to create contact",
message: "Failed to ${id != null ? 'update' : 'create'} contact",
type: SnackbarType.error,
);
}
} catch (e) {
logSafe("Contact creation error: \$e", level: LogLevel.error);
logSafe("Submit contact error: $e", level: LogLevel.error);
showAppSnackbar(
title: "Error",
message: "Something went wrong",
@ -149,9 +156,12 @@ class AddContactController extends GetxController {
final lower = query.toLowerCase();
filteredOrgSuggestions.assignAll(
organizationNames.where((name) => name.toLowerCase().contains(lower)).toList(),
organizationNames
.where((name) => name.toLowerCase().contains(lower))
.toList(),
);
logSafe("Filtered organization suggestions for: \$query", level: LogLevel.debug);
logSafe("Filtered organization suggestions for: \$query",
level: LogLevel.debug);
}
Future<void> fetchGlobalProjects() async {
@ -197,7 +207,10 @@ class AddContactController extends GetxController {
final lower = query.toLowerCase();
filteredSuggestions.assignAll(
tags.where((tag) => tag.toLowerCase().contains(lower) && !enteredTags.contains(tag)).toList(),
tags
.where((tag) =>
tag.toLowerCase().contains(lower) && !enteredTags.contains(tag))
.toList(),
);
logSafe("Filtered tag suggestions for: \$query", level: LogLevel.debug);
}

View File

@ -40,6 +40,7 @@ class ApiEndpoints {
static const String getDirectoryContactTags = "/master/contact-tags";
static const String getDirectoryOrganization = "/directory/organization";
static const String createContact = "/directory";
static const String updateContact = "/directory";
static const String getDirectoryNotes = "/directory/notes";
static const String updateDirectoryNotes = "/directory/note";
}

View File

@ -365,6 +365,30 @@ class ApiService {
return data is List ? data : null;
}
static Future<bool> updateContact(
String contactId, Map<String, dynamic> payload) async {
try {
final endpoint = "${ApiEndpoints.updateContact}/$contactId";
logSafe("Updating contact [$contactId] with payload: $payload");
final response = await _putRequest(endpoint, payload);
if (response != null) {
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Contact updated successfully.");
return true;
} else {
logSafe("Update contact failed: ${json['message']}",
level: LogLevel.warning);
}
}
} catch (e) {
logSafe("Error updating contact: $e", level: LogLevel.error);
}
return false;
}
static Future<bool> createContact(Map<String, dynamic> payload) async {
try {
logSafe("Submitting contact payload: $payload");

View File

@ -1,26 +1,80 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter/services.dart';
import 'package:marco/controller/directory/add_contact_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:flutter/services.dart';
import 'package:marco/model/directory/contact_model.dart';
class AddContactBottomSheet extends StatelessWidget {
AddContactBottomSheet({super.key}) {
final ContactModel? existingContact;
AddContactBottomSheet({super.key, this.existingContact}) {
controller.resetForm();
nameController.clear();
orgController.clear();
nameController.text = existingContact?.name ?? '';
orgController.text = existingContact?.organization ?? '';
tagTextController.clear();
addressController.clear();
descriptionController.clear();
addressController.text = existingContact?.address ?? '';
descriptionController.text = existingContact?.description ?? '';
emailControllers.add(TextEditingController());
emailLabels.add('Office'.obs);
if (existingContact != null) {
emailControllers.clear();
emailLabels.clear();
for (var email in existingContact!.contactEmails) {
emailControllers.add(TextEditingController(text: email.emailAddress));
emailLabels.add((email.label ?? 'Office').obs);
}
phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs);
if (emailControllers.isEmpty) {
emailControllers.add(TextEditingController());
emailLabels.add('Office'.obs);
}
phoneControllers.clear();
phoneLabels.clear();
for (var phone in existingContact!.contactPhones) {
phoneControllers.add(TextEditingController(text: phone.phoneNumber));
phoneLabels.add((phone.label ?? 'Work').obs);
}
if (phoneControllers.isEmpty) {
phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs);
}
controller.selectedCategory.value =
existingContact!.contactCategory?.name ?? '';
if (existingContact!.projectIds?.isNotEmpty == true) {
controller.selectedProject.value = controller.globalProjects
.firstWhereOrNull(
(e) => e == existingContact!.projectIds!.first,
)
?.toString() ??
'';
}
if (existingContact!.bucketIds.isNotEmpty) {
controller.selectedBucket.value = controller.buckets
.firstWhereOrNull(
(b) => b == existingContact!.bucketIds.first,
)
?.toString() ??
'';
}
controller.enteredTags.assignAll(
existingContact!.tags.map((tag) => tag.name).toList(),
);
} else {
emailControllers.add(TextEditingController());
emailLabels.add('Office'.obs);
phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs);
}
}
final controller = Get.put(AddContactController());
@ -113,10 +167,9 @@ class AddContactBottomSheet extends StatelessWidget {
MyText.labelMedium(label),
MySpacing.height(8),
_popupSelector(
hint: "Label",
selectedValue: selectedLabel,
options: options,
),
hint: "Label",
selectedValue: selectedLabel,
options: options),
],
),
),
@ -128,40 +181,35 @@ class AddContactBottomSheet extends StatelessWidget {
MyText.labelMedium(inputLabel),
MySpacing.height(8),
TextFormField(
controller: controller,
keyboardType: inputType,
maxLength: inputType == TextInputType.phone ? 10 : null,
inputFormatters: inputType == TextInputType.phone
? [FilteringTextInputFormatter.digitsOnly]
: [],
decoration: _inputDecoration("Enter $inputLabel").copyWith(
counterText: "", // hides length indicator
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "$inputLabel is required";
}
final trimmed = value.trim();
if (inputType == TextInputType.phone) {
if (!RegExp(r'^\d{10}$').hasMatch(trimmed)) {
return "Enter a valid 10-digit phone number";
}
if (RegExp(r'^0+$').hasMatch(trimmed)) {
return "Phone number cannot be all zeroes";
}
}
if (inputType == TextInputType.emailAddress &&
!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(trimmed)) {
return "Enter a valid email address";
}
return null;
},
),
controller: controller,
keyboardType: inputType,
maxLength: inputType == TextInputType.phone ? 10 : null,
inputFormatters: inputType == TextInputType.phone
? [FilteringTextInputFormatter.digitsOnly]
: [],
decoration: _inputDecoration("Enter $inputLabel")
.copyWith(counterText: ""),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "$inputLabel is required";
}
final trimmed = value.trim();
if (inputType == TextInputType.phone) {
if (!RegExp(r'^\d{10}$').hasMatch(trimmed)) {
return "Enter a valid 10-digit phone number";
}
if (RegExp(r'^0+$').hasMatch(trimmed)) {
return "Phone number cannot be all zeroes";
}
}
if (inputType == TextInputType.emailAddress &&
!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
.hasMatch(trimmed)) {
return "Enter a valid email address";
}
return null;
},
),
],
),
),
@ -177,53 +225,49 @@ class AddContactBottomSheet extends StatelessWidget {
);
}
Widget _buildEmailList() {
return Column(
children: List.generate(emailControllers.length, (index) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildLabeledRow(
"Email Label",
emailLabels[index],
["Office", "Personal", "Other"],
"Email",
emailControllers[index],
TextInputType.emailAddress,
onRemove: emailControllers.length > 1
? () {
emailControllers.removeAt(index);
emailLabels.removeAt(index);
}
: null,
),
);
}),
);
}
Widget _buildEmailList() => Column(
children: List.generate(emailControllers.length, (index) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildLabeledRow(
"Email Label",
emailLabels[index],
["Office", "Personal", "Other"],
"Email",
emailControllers[index],
TextInputType.emailAddress,
onRemove: emailControllers.length > 1
? () {
emailControllers.removeAt(index);
emailLabels.removeAt(index);
}
: null,
),
);
}),
);
Widget _buildPhoneList() {
return Column(
children: List.generate(phoneControllers.length, (index) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildLabeledRow(
"Phone Label",
phoneLabels[index],
["Work", "Mobile", "Other"],
"Phone",
phoneControllers[index],
TextInputType.phone,
onRemove: phoneControllers.length > 1
? () {
phoneControllers.removeAt(index);
phoneLabels.removeAt(index);
}
: null,
),
);
}),
);
}
Widget _buildPhoneList() => Column(
children: List.generate(phoneControllers.length, (index) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildLabeledRow(
"Phone Label",
phoneLabels[index],
["Work", "Mobile", "Other"],
"Phone",
phoneControllers[index],
TextInputType.phone,
onRemove: phoneControllers.length > 1
? () {
phoneControllers.removeAt(index);
phoneLabels.removeAt(index);
}
: null,
),
);
}),
);
Widget _dropdownField({
required String label,
@ -350,8 +394,13 @@ class AddContactBottomSheet extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: MyText.titleMedium("Create New Contact",
fontWeight: 700)),
child: MyText.titleMedium(
existingContact != null
? "Edit Contact"
: "Create New Contact",
fontWeight: 700,
),
),
MySpacing.height(24),
_sectionLabel("Basic Info"),
MySpacing.height(16),
@ -519,8 +568,9 @@ class AddContactBottomSheet extends StatelessWidget {
"phoneNumber": entry.value.text.trim(),
})
.toList();
print("Submitting contact payload , id: ${existingContact?.id}");
controller.submitContact(
id: existingContact?.id,
name: nameController.text.trim(),
organization: orgController.text.trim(),
emails: emails,

View File

@ -14,6 +14,7 @@ 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';
class ContactDetailScreen extends StatefulWidget {
final ContactModel contact;
@ -252,47 +253,72 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
final category = widget.contact.contactCategory?.name ?? "-";
return SingleChildScrollView(
padding: MySpacing.xy(8, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_infoCard("Basic Info", [
_iconInfoRow(Icons.email, "Email", email,
onTap: () => LauncherUtils.launchEmail(email),
onLongPress: () =>
LauncherUtils.copyToClipboard(email, typeLabel: "Email")),
_iconInfoRow(Icons.phone, "Phone", phone,
onTap: () => LauncherUtils.launchPhone(phone),
onLongPress: () =>
LauncherUtils.copyToClipboard(phone, typeLabel: "Phone")),
_iconInfoRow(Icons.location_on, "Address", widget.contact.address),
]),
_infoCard("Organization", [
_iconInfoRow(
Icons.business, "Organization", widget.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(
widget.contact.description,
color: Colors.grey[800],
maxLines: 10,
textAlign: TextAlign.left,
),
return Stack(
children: [
SingleChildScrollView(
padding: MySpacing.fromLTRB(8, 8, 8, 80),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(12),
_infoCard("Basic Info", [
_iconInfoRow(Icons.email, "Email", email,
onTap: () => LauncherUtils.launchEmail(email),
onLongPress: () => LauncherUtils.copyToClipboard(email,
typeLabel: "Email")),
_iconInfoRow(Icons.phone, "Phone", phone,
onTap: () => LauncherUtils.launchPhone(phone),
onLongPress: () => LauncherUtils.copyToClipboard(phone,
typeLabel: "Phone")),
_iconInfoRow(
Icons.location_on, "Address", widget.contact.address),
]),
_infoCard("Organization", [
_iconInfoRow(Icons.business, "Organization",
widget.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(
widget.contact.description,
color: Colors.grey[800],
maxLines: 10,
textAlign: TextAlign.left,
),
),
])
],
),
),
Positioned(
bottom: 20,
right: 20,
child: FloatingActionButton.extended(
backgroundColor: Colors.red,
onPressed: () {
Get.bottomSheet(
AddContactBottomSheet(existingContact: widget.contact),
isScrollControlled: true,
backgroundColor: Colors.transparent,
);
},
icon: const Icon(Icons.edit, color: Colors.white),
label: const Text(
"Edit Contact",
style: TextStyle(color: Colors.white),
),
])
],
),
),
),
],
);
}
@ -408,7 +434,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
),
],
),
MySpacing.height(12),
// Comment Content
if (isEditing && quillController != null)
CommentEditorCard(
@ -453,7 +478,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
bottom: 20,
right: 20,
child: FloatingActionButton.extended(
backgroundColor: Colors.indigo,
backgroundColor: Colors.red,
onPressed: () {
Get.bottomSheet(
AddCommentBottomSheet(contactId: contactId),