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> bucketsMap = <String, String>{}.obs;
final RxMap<String, String> projectsMap = <String, String>{}.obs; final RxMap<String, String> projectsMap = <String, String>{}.obs;
final RxMap<String, String> tagsMap = <String, String>{}.obs; final RxMap<String, String> tagsMap = <String, String>{}.obs;
final RxBool isInitialized = false.obs;
@override @override
void onInit() { void onInit() {
@ -41,6 +42,9 @@ class AddContactController extends GetxController {
fetchCategories(), fetchCategories(),
fetchOrganizationNames(), fetchOrganizationNames(),
]); ]);
// Mark initialization as done
isInitialized.value = true;
} }
void resetForm() { void resetForm() {
@ -90,11 +94,103 @@ class AddContactController extends GetxController {
required String address, required String address,
required String description, required String description,
}) async { }) async {
try { final categoryId = categoriesMap[selectedCategory.value];
final categoryId = categoriesMap[selectedCategory.value]; final bucketId = bucketsMap[selectedBucket.value];
final bucketId = bucketsMap[selectedBucket.value]; final projectId = projectsMap[selectedProject.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 tagObjects = enteredTags.map((tagName) {
final tagId = tagsMap[tagName]; final tagId = tagsMap[tagName];
return tagId != null return tagId != null
@ -104,16 +200,16 @@ class AddContactController extends GetxController {
final body = { final body = {
if (id != null) "id": id, if (id != null) "id": id,
"name": name, "name": name.trim(),
"organization": organization, "organization": organization.trim(),
"contactCategoryId": categoryId, "contactCategoryId": categoryId,
"projectIds": projectId != null ? [projectId] : [], "projectIds": [projectId],
"bucketIds": bucketId != null ? [bucketId] : [], "bucketIds": [bucketId],
"tags": tagObjects, "tags": tagObjects,
"contactEmails": emails, "contactEmails": emails,
"contactPhones": phones, "contactPhones": phones,
"address": address, "address": address.trim(),
"description": description, "description": description.trim(),
}; };
logSafe("${id != null ? 'Updating' : 'Creating'} contact"); logSafe("${id != null ? 'Updating' : 'Creating'} contact");

View File

@ -1,90 +1,30 @@
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:flutter/services.dart';
import 'package:collection/collection.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:marco/model/directory/contact_model.dart'; import 'package:marco/model/directory/contact_model.dart';
class AddContactBottomSheet extends StatelessWidget { class AddContactBottomSheet extends StatefulWidget {
final ContactModel? existingContact; final ContactModel? existingContact;
const AddContactBottomSheet({super.key, this.existingContact});
AddContactBottomSheet({super.key, this.existingContact}) { @override
controller.resetForm(); State<AddContactBottomSheet> createState() => _AddContactBottomSheetState();
}
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);
}
}
class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
final controller = Get.put(AddContactController()); final controller = Get.put(AddContactController());
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
final nameController = TextEditingController(); final nameController = TextEditingController();
final orgController = TextEditingController(); final orgController = TextEditingController();
final tagTextController = TextEditingController();
final addressController = TextEditingController(); final addressController = TextEditingController();
final descriptionController = TextEditingController(); final descriptionController = TextEditingController();
final tagTextController = TextEditingController();
final RxList<TextEditingController> emailControllers = final RxList<TextEditingController> emailControllers =
<TextEditingController>[].obs; <TextEditingController>[].obs;
@ -94,6 +34,94 @@ class AddContactBottomSheet extends StatelessWidget {
<TextEditingController>[].obs; <TextEditingController>[].obs;
final RxList<RxString> phoneLabels = <RxString>[].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( InputDecoration _inputDecoration(String hint) => InputDecoration(
hintText: hint, hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true), hintStyle: MyTextStyle.bodySmall(xMuted: true),
@ -116,47 +144,14 @@ class AddContactBottomSheet extends StatelessWidget {
isDense: true, 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( Widget _buildLabeledRow(
String label, String label,
RxString selectedLabel, RxString selectedLabel,
List<String> options, List<String> options,
String inputLabel, String inputLabel,
TextEditingController controller, TextEditingController controller,
TextInputType inputType, { TextInputType inputType,
VoidCallback? onRemove, {VoidCallback? onRemove}) {
}) {
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -190,22 +185,19 @@ class AddContactBottomSheet extends StatelessWidget {
decoration: _inputDecoration("Enter $inputLabel") decoration: _inputDecoration("Enter $inputLabel")
.copyWith(counterText: ""), .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'^[1-9][0-9]{9}$').hasMatch(trimmed)) {
return "Enter a valid 10-digit phone number"; return "Enter valid phone number";
}
if (RegExp(r'^0+$').hasMatch(trimmed)) {
return "Phone number cannot be all zeroes";
} }
} }
if (inputType == TextInputType.emailAddress && if (inputType == TextInputType.emailAddress &&
!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$') !RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$')
.hasMatch(trimmed)) { .hasMatch(trimmed)) {
return "Enter a valid email address"; return "Enter valid email";
} }
return null; return null;
}, },
@ -269,45 +261,54 @@ class AddContactBottomSheet extends StatelessWidget {
}), }),
); );
Widget _dropdownField({ Widget _popupSelector({
required String label, required String hint,
required RxString selectedValue, required RxString selectedValue,
required RxList<String> options, required List<String> options,
}) { }) {
return Obx(() => SizedBox( return Obx(() => GestureDetector(
height: 48, onTap: () async {
child: PopupMenuButton<String>( final selected = await showMenu<String>(
onSelected: (value) => selectedValue.value = value, context: context,
itemBuilder: (_) => options position: const RelativeRect.fromLTRB(100, 300, 100, 0),
.map((item) => PopupMenuItem(value: item, child: Text(item))) items: options
.toList(), .map((e) => PopupMenuItem(value: e, child: Text(e)))
padding: EdgeInsets.zero, .toList(),
child: Container( );
padding: const EdgeInsets.symmetric(horizontal: 12), if (selected != null) selectedValue.value = selected;
decoration: BoxDecoration( },
border: Border.all(color: Colors.grey.shade300), child: Container(
borderRadius: BorderRadius.circular(8), height: 48,
color: Colors.grey.shade100, padding: const EdgeInsets.symmetric(horizontal: 16),
), decoration: BoxDecoration(
alignment: Alignment.centerLeft, color: Colors.grey.shade100,
child: Row( borderRadius: BorderRadius.circular(12),
mainAxisAlignment: MainAxisAlignment.spaceBetween, border: Border.all(color: Colors.grey.shade300),
children: [ ),
Expanded( alignment: Alignment.centerLeft,
child: Text( child: Row(
selectedValue.value.isEmpty ? label : selectedValue.value, mainAxisAlignment: MainAxisAlignment.spaceBetween,
style: const TextStyle(fontSize: 14), children: [
overflow: TextOverflow.ellipsis, Text(
), selectedValue.value.isNotEmpty ? selectedValue.value : hint,
), style: const TextStyle(fontSize: 14),
const Icon(Icons.arrow_drop_down), ),
], 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() { Widget _tagInputSection() {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -327,7 +328,33 @@ class AddContactBottomSheet extends StatelessWidget {
), ),
Obx(() => controller.filteredSuggestions.isEmpty Obx(() => controller.filteredSuggestions.isEmpty
? const SizedBox() ? 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), MySpacing.height(8),
Obx(() => Wrap( Obx(() => Wrap(
spacing: 8, 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, Widget _buildTextField(String label, TextEditingController controller,
{int maxLines = 1}) { {int maxLines = 1}) {
return Column( return Column(
@ -568,9 +464,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, id: widget.existingContact?.id,
name: nameController.text.trim(), name: nameController.text.trim(),
organization: orgController.text.trim(), organization: orgController.text.trim(),
emails: emails, 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(),
],
),
),
),
),
);
});
}
} }