feat(contact): enhance AddContact functionality with validation and initialization state

This commit is contained in:
Vaibhav Surve 2025-07-08 13:10:15 +05:30
parent 445cd75e03
commit 77e27ff98e
2 changed files with 387 additions and 293 deletions

View File

@ -24,6 +24,7 @@ class AddContactController extends GetxController {
final RxMap<String, String> bucketsMap = <String, String>{}.obs;
final RxMap<String, String> projectsMap = <String, String>{}.obs;
final RxMap<String, String> tagsMap = <String, String>{}.obs;
final RxBool isInitialized = false.obs;
@override
void onInit() {
@ -41,6 +42,9 @@ class AddContactController extends GetxController {
fetchCategories(),
fetchOrganizationNames(),
]);
// Mark initialization as done
isInitialized.value = true;
}
void resetForm() {
@ -90,11 +94,103 @@ class AddContactController extends GetxController {
required String address,
required String description,
}) async {
try {
final categoryId = categoriesMap[selectedCategory.value];
final bucketId = bucketsMap[selectedBucket.value];
final projectId = projectsMap[selectedProject.value];
final categoryId = categoriesMap[selectedCategory.value];
final bucketId = bucketsMap[selectedBucket.value];
final projectId = projectsMap[selectedProject.value];
// === Per-field Validation with Specific Messages ===
if (name.trim().isEmpty) {
showAppSnackbar(
title: "Missing Name",
message: "Please enter the contact name.",
type: SnackbarType.warning,
);
return;
}
if (organization.trim().isEmpty) {
showAppSnackbar(
title: "Missing Organization",
message: "Please enter the organization name.",
type: SnackbarType.warning,
);
return;
}
if (emails.isEmpty) {
showAppSnackbar(
title: "Missing Email",
message: "Please add at least one email.",
type: SnackbarType.warning,
);
return;
}
if (phones.isEmpty) {
showAppSnackbar(
title: "Missing Phone Number",
message: "Please add at least one phone number.",
type: SnackbarType.warning,
);
return;
}
if (address.trim().isEmpty) {
showAppSnackbar(
title: "Missing Address",
message: "Please enter the address.",
type: SnackbarType.warning,
);
return;
}
if (description.trim().isEmpty) {
showAppSnackbar(
title: "Missing Description",
message: "Please enter a description.",
type: SnackbarType.warning,
);
return;
}
if (selectedCategory.value.trim().isEmpty || categoryId == null) {
showAppSnackbar(
title: "Missing Category",
message: "Please select a contact category.",
type: SnackbarType.warning,
);
return;
}
if (selectedBucket.value.trim().isEmpty || bucketId == null) {
showAppSnackbar(
title: "Missing Bucket",
message: "Please select a bucket.",
type: SnackbarType.warning,
);
return;
}
if (selectedProject.value.trim().isEmpty || projectId == null) {
showAppSnackbar(
title: "Missing Project",
message: "Please select a project.",
type: SnackbarType.warning,
);
return;
}
if (enteredTags.isEmpty) {
showAppSnackbar(
title: "Missing Tags",
message: "Please enter at least one tag.",
type: SnackbarType.warning,
);
return;
}
// === Submit if all validations passed ===
try {
final tagObjects = enteredTags.map((tagName) {
final tagId = tagsMap[tagName];
return tagId != null
@ -104,16 +200,16 @@ class AddContactController extends GetxController {
final body = {
if (id != null) "id": id,
"name": name,
"organization": organization,
"name": name.trim(),
"organization": organization.trim(),
"contactCategoryId": categoryId,
"projectIds": projectId != null ? [projectId] : [],
"bucketIds": bucketId != null ? [bucketId] : [],
"projectIds": [projectId],
"bucketIds": [bucketId],
"tags": tagObjects,
"contactEmails": emails,
"contactPhones": phones,
"address": address,
"description": description,
"address": address.trim(),
"description": description.trim(),
};
logSafe("${id != null ? 'Updating' : 'Creating'} contact");

View File

@ -1,90 +1,30 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter/services.dart';
import 'package:collection/collection.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:marco/model/directory/contact_model.dart';
class AddContactBottomSheet extends StatelessWidget {
class AddContactBottomSheet extends StatefulWidget {
final ContactModel? existingContact;
const AddContactBottomSheet({super.key, this.existingContact});
AddContactBottomSheet({super.key, this.existingContact}) {
controller.resetForm();
nameController.text = existingContact?.name ?? '';
orgController.text = existingContact?.organization ?? '';
tagTextController.clear();
addressController.text = existingContact?.address ?? '';
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());
emailLabels.add('Office'.obs);
phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs);
}
}
@override
State<AddContactBottomSheet> createState() => _AddContactBottomSheetState();
}
class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
final controller = Get.put(AddContactController());
final formKey = GlobalKey<FormState>();
final nameController = TextEditingController();
final orgController = TextEditingController();
final tagTextController = TextEditingController();
final addressController = TextEditingController();
final descriptionController = TextEditingController();
final tagTextController = TextEditingController();
final RxList<TextEditingController> emailControllers =
<TextEditingController>[].obs;
@ -94,6 +34,94 @@ class AddContactBottomSheet extends StatelessWidget {
<TextEditingController>[].obs;
final RxList<RxString> phoneLabels = <RxString>[].obs;
@override
void initState() {
super.initState();
controller.resetForm();
nameController.text = widget.existingContact?.name ?? '';
orgController.text = widget.existingContact?.organization ?? '';
addressController.text = widget.existingContact?.address ?? '';
descriptionController.text = widget.existingContact?.description ?? '';
tagTextController.clear();
if (widget.existingContact != null) {
emailControllers.clear();
emailLabels.clear();
for (var email in widget.existingContact!.contactEmails) {
emailControllers.add(TextEditingController(text: email.emailAddress));
emailLabels.add((email.label).obs);
}
if (emailControllers.isEmpty) {
emailControllers.add(TextEditingController());
emailLabels.add('Office'.obs);
}
phoneControllers.clear();
phoneLabels.clear();
for (var phone in widget.existingContact!.contactPhones) {
phoneControllers.add(TextEditingController(text: phone.phoneNumber));
phoneLabels.add((phone.label).obs);
}
if (phoneControllers.isEmpty) {
phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs);
}
controller.enteredTags.assignAll(
widget.existingContact!.tags.map((tag) => tag.name).toList(),
);
ever(controller.isInitialized, (bool ready) {
if (ready) {
final projectId = widget.existingContact!.projectIds?.firstOrNull;
final bucketId = widget.existingContact!.bucketIds.firstOrNull;
final categoryName = widget.existingContact!.contactCategory?.name;
if (categoryName != null) {
controller.selectedCategory.value = categoryName;
}
if (projectId != null) {
final name = controller.projectsMap.entries
.firstWhereOrNull((e) => e.value == projectId)
?.key;
if (name != null) {
controller.selectedProject.value = name;
}
}
if (bucketId != null) {
final name = controller.bucketsMap.entries
.firstWhereOrNull((e) => e.value == bucketId)
?.key;
if (name != null) {
controller.selectedBucket.value = name;
}
}
}
});
} else {
emailControllers.add(TextEditingController());
emailLabels.add('Office'.obs);
phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs);
}
}
@override
void dispose() {
nameController.dispose();
orgController.dispose();
tagTextController.dispose();
addressController.dispose();
descriptionController.dispose();
emailControllers.forEach((e) => e.dispose());
phoneControllers.forEach((p) => p.dispose());
Get.delete<AddContactController>();
super.dispose();
}
InputDecoration _inputDecoration(String hint) => InputDecoration(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
@ -116,47 +144,14 @@ class AddContactBottomSheet extends StatelessWidget {
isDense: true,
);
Widget _popupSelector({
required String hint,
required RxString selectedValue,
required List<String> options,
}) {
return Obx(() => GestureDetector(
onTap: () async {
final selected = await showMenu<String>(
context: Navigator.of(Get.context!).overlay!.context,
position: const RelativeRect.fromLTRB(100, 300, 100, 0),
items: options
.map((e) => PopupMenuItem(value: e, child: Text(e)))
.toList(),
);
if (selected != null) selectedValue.value = selected;
},
child: AbsorbPointer(
child: SizedBox(
height: 48,
child: TextFormField(
readOnly: true,
initialValue: selectedValue.value,
style: const TextStyle(fontSize: 14),
decoration: _inputDecoration(hint).copyWith(
suffixIcon: const Icon(Icons.expand_more),
),
),
),
),
));
}
Widget _buildLabeledRow(
String label,
RxString selectedLabel,
List<String> options,
String inputLabel,
TextEditingController controller,
TextInputType inputType, {
VoidCallback? onRemove,
}) {
String label,
RxString selectedLabel,
List<String> options,
String inputLabel,
TextEditingController controller,
TextInputType inputType,
{VoidCallback? onRemove}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -190,22 +185,19 @@ class AddContactBottomSheet extends StatelessWidget {
decoration: _inputDecoration("Enter $inputLabel")
.copyWith(counterText: ""),
validator: (value) {
if (value == null || value.trim().isEmpty) {
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 (!RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) {
return "Enter valid phone number";
}
}
if (inputType == TextInputType.emailAddress &&
!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
!RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$')
.hasMatch(trimmed)) {
return "Enter a valid email address";
return "Enter valid email";
}
return null;
},
@ -269,45 +261,54 @@ class AddContactBottomSheet extends StatelessWidget {
}),
);
Widget _dropdownField({
required String label,
Widget _popupSelector({
required String hint,
required RxString selectedValue,
required RxList<String> options,
required List<String> options,
}) {
return Obx(() => SizedBox(
height: 48,
child: PopupMenuButton<String>(
onSelected: (value) => selectedValue.value = value,
itemBuilder: (_) => options
.map((item) => PopupMenuItem(value: item, child: Text(item)))
.toList(),
padding: EdgeInsets.zero,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
color: Colors.grey.shade100,
),
alignment: Alignment.centerLeft,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
selectedValue.value.isEmpty ? label : selectedValue.value,
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down),
],
),
return Obx(() => GestureDetector(
onTap: () async {
final selected = await showMenu<String>(
context: context,
position: const RelativeRect.fromLTRB(100, 300, 100, 0),
items: options
.map((e) => PopupMenuItem(value: e, child: Text(e)))
.toList(),
);
if (selected != null) selectedValue.value = selected;
},
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
alignment: Alignment.centerLeft,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
selectedValue.value.isNotEmpty ? selectedValue.value : hint,
style: const TextStyle(fontSize: 14),
),
const Icon(Icons.expand_more, size: 20),
],
),
),
));
}
Widget _sectionLabel(String title) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelLarge(title, fontWeight: 600),
MySpacing.height(4),
Divider(thickness: 1, color: Colors.grey.shade200),
],
);
Widget _tagInputSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -327,7 +328,33 @@ class AddContactBottomSheet extends StatelessWidget {
),
Obx(() => controller.filteredSuggestions.isEmpty
? const SizedBox()
: _buildSuggestionsList()),
: Container(
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
boxShadow: const [
BoxShadow(color: Colors.black12, blurRadius: 4)
],
),
child: ListView.builder(
shrinkWrap: true,
itemCount: controller.filteredSuggestions.length,
itemBuilder: (context, index) {
final suggestion = controller.filteredSuggestions[index];
return ListTile(
dense: true,
title: Text(suggestion),
onTap: () {
controller.addEnteredTag(suggestion);
tagTextController.clear();
controller.clearSuggestions();
},
);
},
),
)),
MySpacing.height(8),
Obx(() => Wrap(
spacing: 8,
@ -342,137 +369,6 @@ class AddContactBottomSheet extends StatelessWidget {
);
}
Widget _buildSuggestionsList() => Container(
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4)],
),
child: ListView.builder(
shrinkWrap: true,
itemCount: controller.filteredSuggestions.length,
itemBuilder: (context, index) {
final suggestion = controller.filteredSuggestions[index];
return ListTile(
dense: true,
title: Text(suggestion),
onTap: () {
controller.addEnteredTag(suggestion);
tagTextController.clear();
controller.clearSuggestions();
},
);
},
),
);
Widget _sectionLabel(String title) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelLarge(title, fontWeight: 600),
MySpacing.height(4),
Divider(thickness: 1, color: Colors.grey.shade200),
],
);
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: MediaQuery.of(context).viewInsets,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: MyText.titleMedium(
existingContact != null
? "Edit Contact"
: "Create New Contact",
fontWeight: 700,
),
),
MySpacing.height(24),
_sectionLabel("Basic Info"),
MySpacing.height(16),
_buildTextField("Name", nameController),
MySpacing.height(16),
_buildOrganizationField(),
MySpacing.height(24),
_sectionLabel("Contact Info"),
MySpacing.height(16),
Obx(() => _buildEmailList()),
TextButton.icon(
onPressed: () {
emailControllers.add(TextEditingController());
emailLabels.add('Office'.obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Email"),
),
Obx(() => _buildPhoneList()),
TextButton.icon(
onPressed: () {
phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Phone"),
),
MySpacing.height(24),
_sectionLabel("Other Details"),
MySpacing.height(16),
MyText.labelMedium("Category"),
MySpacing.height(8),
_dropdownField(
label: "Select Category",
selectedValue: controller.selectedCategory,
options: controller.categories,
),
MySpacing.height(16),
MyText.labelMedium("Select Projects"),
MySpacing.height(8),
_dropdownField(
label: "Select Project",
selectedValue: controller.selectedProject,
options: controller.globalProjects,
),
MySpacing.height(16),
MyText.labelMedium("Select Bucket"),
MySpacing.height(8),
_dropdownField(
label: "Select Bucket",
selectedValue: controller.selectedBucket,
options: controller.buckets,
),
MySpacing.height(16),
MyText.labelMedium("Tags"),
MySpacing.height(8),
_tagInputSection(),
MySpacing.height(16),
_buildTextField("Address", addressController, maxLines: 2),
MySpacing.height(16),
_buildTextField("Description", descriptionController,
maxLines: 2),
MySpacing.height(24),
_buildActionButtons(),
],
),
),
),
),
);
}
Widget _buildTextField(String label, TextEditingController controller,
{int maxLines = 1}) {
return Column(
@ -568,9 +464,9 @@ class AddContactBottomSheet extends StatelessWidget {
"phoneNumber": entry.value.text.trim(),
})
.toList();
print("Submitting contact payload , id: ${existingContact?.id}");
controller.submitContact(
id: existingContact?.id,
id: widget.existingContact?.id,
name: nameController.text.trim(),
organization: orgController.text.trim(),
emails: emails,
@ -594,4 +490,106 @@ class AddContactBottomSheet extends StatelessWidget {
],
);
}
@override
Widget build(BuildContext context) {
return Obx(() {
if (!controller.isInitialized.value) {
return const Center(child: CircularProgressIndicator());
}
return SingleChildScrollView(
padding: MediaQuery.of(context).viewInsets,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: MyText.titleMedium(
widget.existingContact != null
? "Edit Contact"
: "Create New Contact",
fontWeight: 700,
),
),
MySpacing.height(24),
_sectionLabel("Basic Info"),
MySpacing.height(16),
_buildTextField("Name", nameController),
MySpacing.height(16),
_buildOrganizationField(),
MySpacing.height(24),
_sectionLabel("Contact Info"),
MySpacing.height(16),
Obx(() => _buildEmailList()),
TextButton.icon(
onPressed: () {
emailControllers.add(TextEditingController());
emailLabels.add('Office'.obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Email"),
),
Obx(() => _buildPhoneList()),
TextButton.icon(
onPressed: () {
phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Phone"),
),
MySpacing.height(24),
_sectionLabel("Other Details"),
MySpacing.height(16),
MyText.labelMedium("Category"),
MySpacing.height(8),
_popupSelector(
hint: "Select Category",
selectedValue: controller.selectedCategory,
options: controller.categories,
),
MySpacing.height(16),
MyText.labelMedium("Select Projects"),
MySpacing.height(8),
_popupSelector(
hint: "Select Project",
selectedValue: controller.selectedProject,
options: controller.globalProjects,
),
MySpacing.height(16),
MyText.labelMedium("Select Bucket"),
MySpacing.height(8),
_popupSelector(
hint: "Select Bucket",
selectedValue: controller.selectedBucket,
options: controller.buckets,
),
MySpacing.height(16),
MyText.labelMedium("Tags"),
MySpacing.height(8),
_tagInputSection(),
MySpacing.height(16),
_buildTextField("Address", addressController, maxLines: 2),
MySpacing.height(16),
_buildTextField("Description", descriptionController,
maxLines: 2),
MySpacing.height(24),
_buildActionButtons(),
],
),
),
),
),
);
});
}
}