import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.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'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class AddContactBottomSheet extends StatefulWidget { final ContactModel? existingContact; const AddContactBottomSheet({super.key, this.existingContact}); @override State createState() => _AddContactBottomSheetState(); } class _AddContactBottomSheetState extends State { final controller = Get.put(AddContactController()); final formKey = GlobalKey(); final nameCtrl = TextEditingController(); final orgCtrl = TextEditingController(); final addrCtrl = TextEditingController(); final descCtrl = TextEditingController(); final tagCtrl = TextEditingController(); final showAdvanced = false.obs; final bucketError = ''.obs; final emailCtrls = [].obs; final emailLabels = [].obs; final phoneCtrls = [].obs; final phoneLabels = [].obs; @override void initState() { super.initState(); controller.resetForm(); _initFields(); } void _initFields() { final c = widget.existingContact; if (c != null) { nameCtrl.text = c.name; orgCtrl.text = c.organization; addrCtrl.text = c.address; descCtrl.text = c.description; emailCtrls.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)); phoneCtrls.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((e) => e.name)); ever(controller.isInitialized, (bool ready) { if (ready) { final projectIds = c.projectIds; final bucketId = c.bucketIds.firstOrNull; final category = c.contactCategory?.name; if (category != null) controller.selectedCategory.value = category; 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 { emailCtrls.add(TextEditingController()); emailLabels.add('Office'.obs); phoneCtrls.add(TextEditingController()); phoneLabels.add('Work'.obs); } } @override void dispose() { nameCtrl.dispose(); orgCtrl.dispose(); addrCtrl.dispose(); descCtrl.dispose(); tagCtrl.dispose(); emailCtrls.forEach((c) => c.dispose()); phoneCtrls.forEach((c) => c.dispose()); Get.delete(); super.dispose(); } 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, ); Widget _textField(String label, TextEditingController ctrl, {bool required = false, int maxLines = 1}) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.labelMedium(label), MySpacing.height(8), TextFormField( controller: ctrl, maxLines: maxLines, decoration: _inputDecoration("Enter $label"), validator: required ? (v) => (v == null || v.trim().isEmpty) ? "$label is required" : null : null, ), ], ); } Widget _popupSelector(RxString selected, List options, String hint) => Obx(() { return GestureDetector( onTap: () async { final selectedItem = await showMenu( context: context, position: RelativeRect.fromLTRB(100, 300, 100, 0), items: options .map((e) => PopupMenuItem(value: e, child: Text(e))) .toList(), ); if (selectedItem != null) selected.value = selectedItem; }, 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(selected.value.isNotEmpty ? selected.value : hint, style: const TextStyle(fontSize: 14)), const Icon(Icons.expand_more, size: 20), ], ), ), ); }); Widget _dynamicList( RxList ctrls, RxList labels, String labelType, List labelOptions, TextInputType type) { return Obx(() { return Column( children: List.generate(ctrls.length, (i) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.labelMedium("$labelType Label"), MySpacing.height(8), _popupSelector(labels[i], labelOptions, "Label"), ], ), ), MySpacing.width(12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.labelMedium(labelType), MySpacing.height(8), TextFormField( controller: ctrls[i], keyboardType: type, maxLength: type == TextInputType.phone ? 10 : null, inputFormatters: type == TextInputType.phone ? [FilteringTextInputFormatter.digitsOnly] : [], decoration: _inputDecoration("Enter $labelType").copyWith( counterText: "", suffixIcon: type == TextInputType.phone ? IconButton( icon: const Icon(Icons.contact_phone, color: Colors.blue), onPressed: () async { final phone = await ContactPickerHelper .pickIndianPhoneNumber(context); if (phone != null) ctrls[i].text = phone; }, ) : null, ), validator: (value) { if (value == null || value.trim().isEmpty) return null; final trimmed = value.trim(); if (type == TextInputType.phone && !RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) { return "Enter valid phone number"; } if (type == TextInputType.emailAddress && !RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$') .hasMatch(trimmed)) { return "Enter valid email"; } return null; }, ), ], ), ), if (ctrls.length > 1) Padding( padding: const EdgeInsets.only(top: 24), child: IconButton( icon: const Icon(Icons.remove_circle_outline, color: Colors.red), onPressed: () { ctrls.removeAt(i); labels.removeAt(i); }, ), ), ], ), ); }), ); }); } Widget _tagInput() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: 48, child: TextField( controller: tagCtrl, onChanged: controller.filterSuggestions, onSubmitted: (v) { controller.addEnteredTag(v); tagCtrl.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: (_, i) { final suggestion = controller.filteredSuggestions[i]; return ListTile( dense: true, title: Text(suggestion), onTap: () { controller.addEnteredTag(suggestion); tagCtrl.clear(); controller.clearSuggestions(); }, ); }, ), )), MySpacing.height(8), Obx(() => Wrap( spacing: 8, children: controller.enteredTags .map((tag) => Chip( label: Text(tag), onDeleted: () => controller.removeEnteredTag(tag), )) .toList(), )), ], ); } void _handleSubmit() { bool valid = formKey.currentState?.validate() ?? false; if (controller.selectedBucket.value.isEmpty) { bucketError.value = "Bucket is required"; valid = false; } else { bucketError.value = ""; } if (!valid) return; final emails = emailCtrls .asMap() .entries .where((e) => e.value.text.trim().isNotEmpty) .map((e) => { "label": emailLabels[e.key].value, "emailAddress": e.value.text.trim() }) .toList(); final phones = phoneCtrls .asMap() .entries .where((e) => e.value.text.trim().isNotEmpty) .map((e) => { "label": phoneLabels[e.key].value, "phoneNumber": e.value.text.trim() }) .toList(); controller.submitContact( id: widget.existingContact?.id, name: nameCtrl.text.trim(), organization: orgCtrl.text.trim(), emails: emails, phones: phones, address: addrCtrl.text.trim(), description: descCtrl.text.trim(), ); } @override Widget build(BuildContext context) { return Obx(() { if (!controller.isInitialized.value) { return const Center(child: CircularProgressIndicator()); } return BaseBottomSheet( title: widget.existingContact != null ? "Edit Contact" : "Create New Contact", onCancel: () => Get.back(), onSubmit: _handleSubmit, isSubmitting: controller.isSubmitting.value, child: Form( key: formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _textField("Name", nameCtrl, required: true), MySpacing.height(16), _textField("Organization", orgCtrl, required: true), MySpacing.height(16), MyText.labelMedium(" Bucket"), MySpacing.height(8), Stack( children: [ _popupSelector(controller.selectedBucket, controller.buckets, "Choose Bucket"), Positioned( left: 0, right: 0, top: 56, child: Obx(() => bucketError.value.isEmpty ? const SizedBox.shrink() : Padding( padding: const EdgeInsets.symmetric(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), _dynamicList( emailCtrls, emailLabels, "Email", ["Office", "Personal", "Other"], TextInputType.emailAddress), TextButton.icon( onPressed: () { emailCtrls.add(TextEditingController()); emailLabels.add("Office".obs); }, icon: const Icon(Icons.add), label: const Text("Add Email"), ), _dynamicList(phoneCtrls, phoneLabels, "Phone", ["Work", "Mobile", "Other"], TextInputType.phone), TextButton.icon( onPressed: () { phoneCtrls.add(TextEditingController()); phoneLabels.add("Work".obs); }, icon: const Icon(Icons.add), label: const Text("Add Phone"), ), MySpacing.height(16), MyText.labelMedium("Category"), MySpacing.height(8), _popupSelector(controller.selectedCategory, controller.categories, "Choose Category"), MySpacing.height(16), MyText.labelMedium("Tags"), MySpacing.height(8), _tagInput(), MySpacing.height(16), _textField("Address", addrCtrl), MySpacing.height(16), _textField("Description", descCtrl), ], ) : const SizedBox.shrink()), ], ), ), ); }); } }