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'; import 'package:marco/helpers/utils/contact_picker_helper.dart'; class AddContactBottomSheet extends StatefulWidget { final ContactModel? existingContact; const AddContactBottomSheet({super.key, this.existingContact}); @override State createState() => _AddContactBottomSheetState(); } class _AddContactBottomSheetState extends State { // Controllers and state final AddContactController controller = Get.put(AddContactController()); final formKey = GlobalKey(); final nameController = TextEditingController(); final orgController = TextEditingController(); final addressController = TextEditingController(); final descriptionController = TextEditingController(); final tagTextController = TextEditingController(); // Use Rx for advanced toggle and dynamic fields final showAdvanced = false.obs; final emailControllers = [].obs; final emailLabels = [].obs; final phoneControllers = [].obs; final phoneLabels = [].obs; // For required bucket validation (new) final bucketError = ''.obs; @override void initState() { super.initState(); controller.resetForm(); _initFields(); } void _initFields() { final c = widget.existingContact; if (c != null) { nameController.text = c.name; orgController.text = c.organization; addressController.text = c.address; descriptionController.text = c.description ; } if (c != null) { emailControllers.assignAll(c.contactEmails.isEmpty ? [TextEditingController()] : c.contactEmails.map((e) => TextEditingController(text: e.emailAddress))); emailLabels.assignAll(c.contactEmails.isEmpty ? ['Office'.obs] : c.contactEmails.map((e) => e.label.obs)); phoneControllers.assignAll(c.contactPhones.isEmpty ? [TextEditingController()] : c.contactPhones.map((p) => TextEditingController(text: p.phoneNumber))); phoneLabels.assignAll(c.contactPhones.isEmpty ? ['Work'.obs] : c.contactPhones.map((p) => p.label.obs)); controller.enteredTags.assignAll(c.tags.map((tag) => tag.name)); ever(controller.isInitialized, (bool ready) { if (ready) { final projectIds = c.projectIds; final bucketId = c.bucketIds.firstOrNull; final categoryName = c.contactCategory?.name; if (categoryName != null) controller.selectedCategory.value = categoryName; if (projectIds != null) { controller.selectedProjects.assignAll( projectIds // .map((id) => controller.projectsMap.entries .firstWhereOrNull((e) => e.value == id) ?.key) .whereType() .toList(), ); } 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); } tagTextController.clear(); } @override void dispose() { nameController.dispose(); orgController.dispose(); tagTextController.dispose(); addressController.dispose(); descriptionController.dispose(); for (final c in emailControllers) { c.dispose(); } for (final c in phoneControllers) { c.dispose(); } Get.delete(); super.dispose(); } // --- COMMON WIDGETS --- InputDecoration _inputDecoration(String hint) => InputDecoration( hintText: hint, hintStyle: MyTextStyle.bodySmall(xMuted: true), filled: true, fillColor: Colors.grey.shade100, border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade300), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade300), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), isDense: true, ); // DRY'd: LABELED FIELD ROW (used for phone/email) Widget _buildLabeledRow({ required String label, required RxString selectedLabel, required List options, required String inputLabel, required TextEditingController controller, required TextInputType inputType, VoidCallback? onRemove, Widget? suffixIcon, }) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.labelMedium(label), MySpacing.height(8), _popupSelector( hint: "Label", selectedValue: selectedLabel, options: options, ), ], ), ), MySpacing.width(12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.labelMedium(inputLabel), MySpacing.height(8), TextFormField( controller: controller, keyboardType: inputType, maxLength: inputType == TextInputType.phone ? 10 : null, inputFormatters: inputType == TextInputType.phone ? [FilteringTextInputFormatter.digitsOnly] : [], decoration: _inputDecoration("Enter $inputLabel").copyWith( counterText: "", suffixIcon: suffixIcon, ), validator: (value) { if (value == null || value.trim().isEmpty) return null; final trimmed = value.trim(); if (inputType == TextInputType.phone && !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}$').hasMatch(trimmed)) { return "Enter valid email"; } return null; }, ), ], ), ), if (onRemove != null) Padding( padding: const EdgeInsets.only(top: 24), child: IconButton( icon: const Icon(Icons.remove_circle_outline, color: Colors.red), onPressed: onRemove, ), ), ], ); } // DRY: List builder for email/phone fields Widget _buildDynamicList({ required RxList ctrls, required RxList labels, required List labelOptions, required String label, required String inputLabel, required TextInputType inputType, required RxList listToRemoveFrom, Widget? phoneSuffixIcon, }) { return Obx(() { return Column( children: List.generate(ctrls.length, (index) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: _buildLabeledRow( label: label, selectedLabel: labels[index], options: labelOptions, inputLabel: inputLabel, controller: ctrls[index], inputType: inputType, onRemove: ctrls.length > 1 ? () { ctrls.removeAt(index); labels.removeAt(index); } : null, suffixIcon: phoneSuffixIcon != null && inputType == TextInputType.phone ? IconButton( icon: const Icon(Icons.contact_phone, color: Colors.blue), onPressed: () async { final selectedPhone = await ContactPickerHelper.pickIndianPhoneNumber(context); if (selectedPhone != null) { ctrls[index].text = selectedPhone; } }, ) : null, ), ); }), ); }); } Widget _buildEmailList() => _buildDynamicList( ctrls: emailControllers, labels: emailLabels, labelOptions: ["Office", "Personal", "Other"], label: "Email Label", inputLabel: "Email", inputType: TextInputType.emailAddress, listToRemoveFrom: emailControllers, ); Widget _buildPhoneList() => _buildDynamicList( ctrls: phoneControllers, labels: phoneLabels, labelOptions: ["Work", "Mobile", "Other"], label: "Phone Label", inputLabel: "Phone", inputType: TextInputType.phone, listToRemoveFrom: phoneControllers, phoneSuffixIcon: const Icon(Icons.contact_phone, color: Colors.blue), ); Widget _popupSelector({ required String hint, required RxString selectedValue, required List options, }) => Obx(() => GestureDetector( onTap: () async { final selected = await showMenu( context: context, position: RelativeRect.fromLTRB(100, 300, 100, 0), items: options.map((option) => PopupMenuItem(value: option, child: Text(option))).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), ], ); // CHIP list for tags Widget _tagInputSection() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: 48, child: TextField( controller: tagTextController, onChanged: controller.filterSuggestions, onSubmitted: (value) { controller.addEnteredTag(value); tagTextController.clear(); controller.clearSuggestions(); }, decoration: _inputDecoration("Start typing to add tags"), ), ), Obx(() => controller.filteredSuggestions.isEmpty ? const SizedBox.shrink() : 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, children: controller.enteredTags .map((tag) => Chip( label: Text(tag), onDeleted: () => controller.removeEnteredTag(tag), )) .toList(), )), ], ); } // ---- REQUIRED FIELD (reusable) Widget _buildTextField( String label, TextEditingController controller, { int maxLines = 1, bool required = false, }) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.labelMedium(label), MySpacing.height(8), TextFormField( controller: controller, maxLines: maxLines, decoration: _inputDecoration("Enter $label"), validator: required ? (value) => value == null || value.trim().isEmpty ? "$label is required" : null : null, ), ], ); // -- Organization as required TextFormField Widget _buildOrganizationField() => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.labelMedium("Organization"), MySpacing.height(8), TextFormField( controller: orgController, onChanged: controller.filterOrganizationSuggestions, decoration: _inputDecoration("Enter organization"), validator: (value) => value == null || value.trim().isEmpty ? "Organization is required" : null, ), Obx(() => controller.filteredOrgSuggestions.isEmpty ? const SizedBox.shrink() : ListView.builder( shrinkWrap: true, itemCount: controller.filteredOrgSuggestions.length, itemBuilder: (context, index) { final suggestion = controller.filteredOrgSuggestions[index]; return ListTile( dense: true, title: Text(suggestion), onTap: () { orgController.text = suggestion; controller.filteredOrgSuggestions.clear(); }, ); }, )), ], ); // Action button row Widget _buildActionButtons() => Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: () { Get.back(); Get.delete(); }, icon: const Icon(Icons.close, color: Colors.red), label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), style: OutlinedButton.styleFrom( side: const BorderSide(color: Colors.red), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), ), ), ), MySpacing.width(12), Expanded( child: ElevatedButton.icon( onPressed: () { // Validate bucket first in UI and show error under dropdown if empty bool valid = formKey.currentState!.validate(); if (controller.selectedBucket.value.isEmpty) { bucketError.value = "Bucket is required"; valid = false; } else { bucketError.value = ""; } if (valid) { final emails = emailControllers .asMap() .entries .where((entry) => entry.value.text.trim().isNotEmpty) .map((entry) => { "label": emailLabels[entry.key].value, "emailAddress": entry.value.text.trim(), }) .toList(); final phones = phoneControllers .asMap() .entries .where((entry) => entry.value.text.trim().isNotEmpty) .map((entry) => { "label": phoneLabels[entry.key].value, "phoneNumber": entry.value.text.trim(), }) .toList(); controller.submitContact( id: widget.existingContact?.id, name: nameController.text.trim(), organization: orgController.text.trim(), emails: emails, phones: phones, address: addressController.text.trim(), description: descriptionController.text.trim(), ); } }, icon: const Icon(Icons.check_circle_outline, color: Colors.white), label: MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600), style: ElevatedButton.styleFrom( backgroundColor: Colors.indigo, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), ), ), ), ], ); // Projects multi-select section Widget _projectSelectorUI() { return GestureDetector( onTap: () async { await showDialog( context: context, builder: (_) { return AlertDialog( title: const Text('Select Projects'), content: Obx(() => SizedBox( width: double.maxFinite, child: ListView( shrinkWrap: true, children: controller.globalProjects.map((project) { final isSelected = controller.selectedProjects.contains(project); return Theme( data: Theme.of(context).copyWith( checkboxTheme: CheckboxThemeData( fillColor: MaterialStateProperty.resolveWith( (states) => states.contains(MaterialState.selected) ? Colors.white : Colors.transparent), checkColor: MaterialStateProperty.all(Colors.black), side: const BorderSide(color: Colors.black, width: 2), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(4), ), ), ), child: CheckboxListTile( dense: true, title: Text(project), value: isSelected, onChanged: (selected) { if (selected == true) { controller.selectedProjects.add(project); } else { controller.selectedProjects.remove(project); } }, ), ); }).toList(), ), )), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Done'), ), ], ); }, ); }, 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: Obx(() { final selected = controller.selectedProjects; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text( selected.isEmpty ? "Select Projects" : selected.join(', '), overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 14), ), ), const Icon(Icons.expand_more, size: 20), ], ); }), ), ); } // --- MAIN BUILD --- @override Widget build(BuildContext context) { return Obx(() { if (!controller.isInitialized.value) { return const Center(child: CircularProgressIndicator()); } return SafeArea( child: SingleChildScrollView( padding: EdgeInsets.only(top: 32).add(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("Required Fields"), MySpacing.height(12), _buildTextField("Name", nameController, required: true), MySpacing.height(16), _buildOrganizationField(), MySpacing.height(16), MyText.labelMedium("Select Bucket"), MySpacing.height(8), Stack( children: [ _popupSelector( hint: "Select Bucket", selectedValue: controller.selectedBucket, options: controller.buckets, ), // Validation message for bucket Positioned( left: 0, right: 0, top: 56, child: Obx( () => bucketError.value.isEmpty ? const SizedBox.shrink() : Padding( padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), child: Text( bucketError.value, style: const TextStyle(color: Colors.red, fontSize: 12), ), ), ), ), ], ), MySpacing.height(24), Obx(() => GestureDetector( onTap: () => showAdvanced.toggle(), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ MyText.labelLarge("Advanced Details (Optional)", fontWeight: 600), Icon(showAdvanced.value ? Icons.expand_less : Icons.expand_more), ], ), )), Obx(() => showAdvanced.value ? Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MySpacing.height(24), _sectionLabel("Contact Info"), MySpacing.height(16), _buildEmailList(), TextButton.icon( onPressed: () { emailControllers.add(TextEditingController()); emailLabels.add('Office'.obs); }, icon: const Icon(Icons.add), label: const Text("Add Email"), ), _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), _projectSelectorUI(), MySpacing.height(16), MyText.labelMedium("Tags"), MySpacing.height(8), _tagInputSection(), MySpacing.height(16), _buildTextField("Address", addressController, maxLines: 2, required: false), MySpacing.height(16), _buildTextField("Description", descriptionController, maxLines: 2, required: false), ], ) : const SizedBox.shrink()), MySpacing.height(24), _buildActionButtons(), ], ), ), ), ), ), ); }); } }