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,26 +1,80 @@
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 ?? '';
emailControllers.add(TextEditingController()); if (existingContact != null) {
emailLabels.add('Office'.obs); 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()); if (emailControllers.isEmpty) {
phoneLabels.add('Work'.obs); 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()); final controller = Get.put(AddContactController());
@ -113,10 +167,9 @@ class AddContactBottomSheet extends StatelessWidget {
MyText.labelMedium(label), MyText.labelMedium(label),
MySpacing.height(8), MySpacing.height(8),
_popupSelector( _popupSelector(
hint: "Label", hint: "Label",
selectedValue: selectedLabel, selectedValue: selectedLabel,
options: options, options: options),
),
], ],
), ),
), ),
@ -128,40 +181,35 @@ class AddContactBottomSheet extends StatelessWidget {
MyText.labelMedium(inputLabel), MyText.labelMedium(inputLabel),
MySpacing.height(8), MySpacing.height(8),
TextFormField( TextFormField(
controller: controller, controller: controller,
keyboardType: inputType, keyboardType: inputType,
maxLength: inputType == TextInputType.phone ? 10 : null, maxLength: inputType == TextInputType.phone ? 10 : null,
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();
if (inputType == TextInputType.phone) {
final trimmed = value.trim(); if (!RegExp(r'^\d{10}$').hasMatch(trimmed)) {
return "Enter a valid 10-digit phone number";
if (inputType == TextInputType.phone) { }
if (!RegExp(r'^\d{10}$').hasMatch(trimmed)) { if (RegExp(r'^0+$').hasMatch(trimmed)) {
return "Enter a valid 10-digit phone number"; return "Phone number cannot be all zeroes";
} }
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";
if (inputType == TextInputType.emailAddress && }
!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(trimmed)) { return null;
return "Enter a valid email address"; },
} ),
return null;
},
),
], ],
), ),
), ),
@ -177,53 +225,49 @@ 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), child: _buildLabeledRow(
child: _buildLabeledRow( "Email Label",
"Email Label", emailLabels[index],
emailLabels[index], ["Office", "Personal", "Other"],
["Office", "Personal", "Other"], "Email",
"Email", emailControllers[index],
emailControllers[index], TextInputType.emailAddress,
TextInputType.emailAddress, onRemove: emailControllers.length > 1
onRemove: emailControllers.length > 1 ? () {
? () { emailControllers.removeAt(index);
emailControllers.removeAt(index); emailLabels.removeAt(index);
emailLabels.removeAt(index); }
} : null,
: null, ),
), );
); }),
}), );
);
}
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), child: _buildLabeledRow(
child: _buildLabeledRow( "Phone Label",
"Phone Label", phoneLabels[index],
phoneLabels[index], ["Work", "Mobile", "Other"],
["Work", "Mobile", "Other"], "Phone",
"Phone", phoneControllers[index],
phoneControllers[index], TextInputType.phone,
TextInputType.phone, onRemove: phoneControllers.length > 1
onRemove: phoneControllers.length > 1 ? () {
? () { phoneControllers.removeAt(index);
phoneControllers.removeAt(index); phoneLabels.removeAt(index);
phoneLabels.removeAt(index); }
} : null,
: null, ),
), );
); }),
}), );
);
}
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,47 +253,72 @@ 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: [
child: Column( SingleChildScrollView(
crossAxisAlignment: CrossAxisAlignment.start, padding: MySpacing.fromLTRB(8, 8, 8, 80),
children: [ child: Column(
_infoCard("Basic Info", [ crossAxisAlignment: CrossAxisAlignment.start,
_iconInfoRow(Icons.email, "Email", email, children: [
onTap: () => LauncherUtils.launchEmail(email), MySpacing.height(12),
onLongPress: () => _infoCard("Basic Info", [
LauncherUtils.copyToClipboard(email, typeLabel: "Email")), _iconInfoRow(Icons.email, "Email", email,
_iconInfoRow(Icons.phone, "Phone", phone, onTap: () => LauncherUtils.launchEmail(email),
onTap: () => LauncherUtils.launchPhone(phone), onLongPress: () => LauncherUtils.copyToClipboard(email,
onLongPress: () => typeLabel: "Email")),
LauncherUtils.copyToClipboard(phone, typeLabel: "Phone")), _iconInfoRow(Icons.phone, "Phone", phone,
_iconInfoRow(Icons.location_on, "Address", widget.contact.address), onTap: () => LauncherUtils.launchPhone(phone),
]), onLongPress: () => LauncherUtils.copyToClipboard(phone,
_infoCard("Organization", [ typeLabel: "Phone")),
_iconInfoRow( _iconInfoRow(
Icons.business, "Organization", widget.contact.organization), Icons.location_on, "Address", widget.contact.address),
_iconInfoRow(Icons.category, "Category", category), ]),
]), _infoCard("Organization", [
_infoCard("Meta Info", [ _iconInfoRow(Icons.business, "Organization",
_iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"), widget.contact.organization),
_iconInfoRow(Icons.folder_shared, "Contact Buckets", _iconInfoRow(Icons.category, "Category", category),
bucketNames.isNotEmpty ? bucketNames : "-"), ]),
_iconInfoRow(Icons.work_outline, "Projects", projectNames), _infoCard("Meta Info", [
]), _iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"),
_infoCard("Description", [ _iconInfoRow(Icons.folder_shared, "Contact Buckets",
MySpacing.height(6), bucketNames.isNotEmpty ? bucketNames : "-"),
Align( _iconInfoRow(Icons.work_outline, "Projects", projectNames),
alignment: Alignment.topLeft, ]),
child: MyText.bodyMedium( _infoCard("Description", [
widget.contact.description, MySpacing.height(6),
color: Colors.grey[800], Align(
maxLines: 10, alignment: Alignment.topLeft,
textAlign: TextAlign.left, 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 // 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),