From 77e27ff98e23fcaa4359e1d7d63659abb07101fb Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 8 Jul 2025 13:10:15 +0530 Subject: [PATCH] feat(contact): enhance AddContact functionality with validation and initialization state --- .../directory/add_contact_controller.dart | 116 +++- .../directory/add_contact_bottom_sheet.dart | 564 +++++++++--------- 2 files changed, 387 insertions(+), 293 deletions(-) diff --git a/lib/controller/directory/add_contact_controller.dart b/lib/controller/directory/add_contact_controller.dart index aa3f79c..63c29bd 100644 --- a/lib/controller/directory/add_contact_controller.dart +++ b/lib/controller/directory/add_contact_controller.dart @@ -24,6 +24,7 @@ class AddContactController extends GetxController { final RxMap bucketsMap = {}.obs; final RxMap projectsMap = {}.obs; final RxMap tagsMap = {}.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"); diff --git a/lib/model/directory/add_contact_bottom_sheet.dart b/lib/model/directory/add_contact_bottom_sheet.dart index 92d81e2..c509df4 100644 --- a/lib/model/directory/add_contact_bottom_sheet.dart +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -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 createState() => _AddContactBottomSheetState(); +} +class _AddContactBottomSheetState extends State { final controller = Get.put(AddContactController()); final formKey = GlobalKey(); final nameController = TextEditingController(); final orgController = TextEditingController(); - final tagTextController = TextEditingController(); final addressController = TextEditingController(); final descriptionController = TextEditingController(); + final tagTextController = TextEditingController(); final RxList emailControllers = [].obs; @@ -94,6 +34,94 @@ class AddContactBottomSheet extends StatelessWidget { [].obs; final RxList phoneLabels = [].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(); + 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 options, - }) { - return Obx(() => GestureDetector( - onTap: () async { - final selected = await showMenu( - 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 options, - String inputLabel, - TextEditingController controller, - TextInputType inputType, { - VoidCallback? onRemove, - }) { + String label, + RxString selectedLabel, + List 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 options, + required List options, }) { - return Obx(() => SizedBox( - height: 48, - child: PopupMenuButton( - 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( + 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(), + ], + ), + ), + ), + ), + ); + }); + } }