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({ Future<void> submitContact({
String? id,
required String name, required String name,
required String organization, required String organization,
required List<Map<String, String>> emails, required List<Map<String, String>> emails,
@ -96,10 +97,13 @@ class AddContactController extends GetxController {
final tagObjects = enteredTags.map((tagName) { final tagObjects = enteredTags.map((tagName) {
final tagId = tagsMap[tagName]; final tagId = tagsMap[tagName];
return tagId != null ? {"id": tagId, "name": tagName} : {"name": tagName}; return tagId != null
? {"id": tagId, "name": tagName}
: {"name": tagName};
}).toList(); }).toList();
final body = { final body = {
if (id != null) "id": id,
"name": name, "name": name,
"organization": organization, "organization": organization,
"contactCategoryId": categoryId, "contactCategoryId": categoryId,
@ -112,27 +116,30 @@ class AddContactController extends GetxController {
"description": description, "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) { if (response == true) {
logSafe("Contact creation succeeded");
Get.back(result: true); Get.back(result: true);
showAppSnackbar( showAppSnackbar(
title: "Success", title: "Success",
message: "Contact created successfully", message: id != null
? "Contact updated successfully"
: "Contact created successfully",
type: SnackbarType.success, type: SnackbarType.success,
); );
} else { } else {
logSafe("Contact creation failed", level: LogLevel.error);
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Failed to create contact", message: "Failed to ${id != null ? 'update' : 'create'} contact",
type: SnackbarType.error, type: SnackbarType.error,
); );
} }
} catch (e) { } catch (e) {
logSafe("Contact creation error: \$e", level: LogLevel.error); logSafe("Submit contact error: $e", level: LogLevel.error);
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Something went wrong", message: "Something went wrong",
@ -149,9 +156,12 @@ class AddContactController extends GetxController {
final lower = query.toLowerCase(); final lower = query.toLowerCase();
filteredOrgSuggestions.assignAll( 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 { Future<void> fetchGlobalProjects() async {
@ -197,7 +207,10 @@ class AddContactController extends GetxController {
final lower = query.toLowerCase(); final lower = query.toLowerCase();
filteredSuggestions.assignAll( 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); 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 getDirectoryContactTags = "/master/contact-tags";
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 updateContact = "/directory";
static const String getDirectoryNotes = "/directory/notes"; static const String getDirectoryNotes = "/directory/notes";
static const String updateDirectoryNotes = "/directory/note"; static const String updateDirectoryNotes = "/directory/note";
} }

View File

@ -365,6 +365,30 @@ class ApiService {
return data is List ? data : null; 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 { static Future<bool> createContact(Map<String, dynamic> payload) async {
try { try {
logSafe("Submitting contact payload: $payload"); logSafe("Submitting contact payload: $payload");

View File

@ -1,27 +1,81 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:flutter/services.dart';
import 'package:marco/controller/directory/add_contact_controller.dart'; import 'package:marco/controller/directory/add_contact_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.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 { class AddContactBottomSheet extends StatelessWidget {
AddContactBottomSheet({super.key}) { final ContactModel? existingContact;
AddContactBottomSheet({super.key, this.existingContact}) {
controller.resetForm(); controller.resetForm();
nameController.clear(); nameController.text = existingContact?.name ?? '';
orgController.clear(); orgController.text = existingContact?.organization ?? '';
tagTextController.clear(); tagTextController.clear();
addressController.clear(); addressController.text = existingContact?.address ?? '';
descriptionController.clear(); descriptionController.text = existingContact?.description ?? '';
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);
}
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()); emailControllers.add(TextEditingController());
emailLabels.add('Office'.obs); emailLabels.add('Office'.obs);
phoneControllers.add(TextEditingController()); phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs); phoneLabels.add('Work'.obs);
} }
}
final controller = Get.put(AddContactController()); final controller = Get.put(AddContactController());
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
@ -115,8 +169,7 @@ class AddContactBottomSheet extends StatelessWidget {
_popupSelector( _popupSelector(
hint: "Label", hint: "Label",
selectedValue: selectedLabel, selectedValue: selectedLabel,
options: options, options: options),
),
], ],
), ),
), ),
@ -134,16 +187,13 @@ class AddContactBottomSheet extends StatelessWidget {
inputFormatters: inputType == TextInputType.phone inputFormatters: inputType == TextInputType.phone
? [FilteringTextInputFormatter.digitsOnly] ? [FilteringTextInputFormatter.digitsOnly]
: [], : [],
decoration: _inputDecoration("Enter $inputLabel").copyWith( decoration: _inputDecoration("Enter $inputLabel")
counterText: "", // hides length indicator .copyWith(counterText: ""),
),
validator: (value) { validator: (value) {
if (value == null || value.trim().isEmpty) { if (value == null || value.trim().isEmpty) {
return "$inputLabel is required"; return "$inputLabel is required";
} }
final trimmed = value.trim(); final trimmed = value.trim();
if (inputType == TextInputType.phone) { if (inputType == TextInputType.phone) {
if (!RegExp(r'^\d{10}$').hasMatch(trimmed)) { if (!RegExp(r'^\d{10}$').hasMatch(trimmed)) {
return "Enter a valid 10-digit phone number"; return "Enter a valid 10-digit phone number";
@ -152,16 +202,14 @@ class AddContactBottomSheet extends StatelessWidget {
return "Phone number cannot be all zeroes"; return "Phone number cannot be all zeroes";
} }
} }
if (inputType == TextInputType.emailAddress && if (inputType == TextInputType.emailAddress &&
!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(trimmed)) { !RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
.hasMatch(trimmed)) {
return "Enter a valid email address"; return "Enter a valid email address";
} }
return null; return null;
}, },
), ),
], ],
), ),
), ),
@ -177,8 +225,7 @@ class AddContactBottomSheet extends StatelessWidget {
); );
} }
Widget _buildEmailList() { Widget _buildEmailList() => Column(
return Column(
children: List.generate(emailControllers.length, (index) { children: List.generate(emailControllers.length, (index) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
@ -199,10 +246,8 @@ class AddContactBottomSheet extends StatelessWidget {
); );
}), }),
); );
}
Widget _buildPhoneList() { Widget _buildPhoneList() => Column(
return Column(
children: List.generate(phoneControllers.length, (index) { children: List.generate(phoneControllers.length, (index) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
@ -223,7 +268,6 @@ class AddContactBottomSheet extends StatelessWidget {
); );
}), }),
); );
}
Widget _dropdownField({ Widget _dropdownField({
required String label, required String label,
@ -350,8 +394,13 @@ class AddContactBottomSheet extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Center( Center(
child: MyText.titleMedium("Create New Contact", child: MyText.titleMedium(
fontWeight: 700)), existingContact != null
? "Edit Contact"
: "Create New Contact",
fontWeight: 700,
),
),
MySpacing.height(24), MySpacing.height(24),
_sectionLabel("Basic Info"), _sectionLabel("Basic Info"),
MySpacing.height(16), MySpacing.height(16),
@ -519,8 +568,9 @@ class AddContactBottomSheet extends StatelessWidget {
"phoneNumber": entry.value.text.trim(), "phoneNumber": entry.value.text.trim(),
}) })
.toList(); .toList();
print("Submitting contact payload , id: ${existingContact?.id}");
controller.submitContact( controller.submitContact(
id: existingContact?.id,
name: nameController.text.trim(), name: nameController.text.trim(),
organization: orgController.text.trim(), organization: orgController.text.trim(),
emails: emails, 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:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart';
import 'package:marco/model/directory/add_comment_bottom_sheet.dart'; import 'package:marco/model/directory/add_comment_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
class ContactDetailScreen extends StatefulWidget { class ContactDetailScreen extends StatefulWidget {
final ContactModel contact; final ContactModel contact;
@ -252,25 +253,29 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
final category = widget.contact.contactCategory?.name ?? "-"; final category = widget.contact.contactCategory?.name ?? "-";
return SingleChildScrollView( return Stack(
padding: MySpacing.xy(8, 8), children: [
SingleChildScrollView(
padding: MySpacing.fromLTRB(8, 8, 8, 80),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MySpacing.height(12),
_infoCard("Basic Info", [ _infoCard("Basic Info", [
_iconInfoRow(Icons.email, "Email", email, _iconInfoRow(Icons.email, "Email", email,
onTap: () => LauncherUtils.launchEmail(email), onTap: () => LauncherUtils.launchEmail(email),
onLongPress: () => onLongPress: () => LauncherUtils.copyToClipboard(email,
LauncherUtils.copyToClipboard(email, typeLabel: "Email")), typeLabel: "Email")),
_iconInfoRow(Icons.phone, "Phone", phone, _iconInfoRow(Icons.phone, "Phone", phone,
onTap: () => LauncherUtils.launchPhone(phone), onTap: () => LauncherUtils.launchPhone(phone),
onLongPress: () => onLongPress: () => LauncherUtils.copyToClipboard(phone,
LauncherUtils.copyToClipboard(phone, typeLabel: "Phone")), typeLabel: "Phone")),
_iconInfoRow(Icons.location_on, "Address", widget.contact.address), _iconInfoRow(
Icons.location_on, "Address", widget.contact.address),
]), ]),
_infoCard("Organization", [ _infoCard("Organization", [
_iconInfoRow( _iconInfoRow(Icons.business, "Organization",
Icons.business, "Organization", widget.contact.organization), widget.contact.organization),
_iconInfoRow(Icons.category, "Category", category), _iconInfoRow(Icons.category, "Category", category),
]), ]),
_infoCard("Meta Info", [ _infoCard("Meta Info", [
@ -293,6 +298,27 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
]) ])
], ],
), ),
),
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 // Comment Content
if (isEditing && quillController != null) if (isEditing && quillController != null)
CommentEditorCard( CommentEditorCard(
@ -453,7 +478,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
bottom: 20, bottom: 20,
right: 20, right: 20,
child: FloatingActionButton.extended( child: FloatingActionButton.extended(
backgroundColor: Colors.indigo, backgroundColor: Colors.red,
onPressed: () { onPressed: () {
Get.bottomSheet( Get.bottomSheet(
AddCommentBottomSheet(contactId: contactId), AddCommentBottomSheet(contactId: contactId),