diff --git a/lib/controller/directory/add_contact_controller.dart b/lib/controller/directory/add_contact_controller.dart index 3cfcc57..d5b9d91 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 tagsMap = {}.obs; final RxBool isInitialized = false.obs; final RxList selectedProjects = [].obs; + final RxBool isSubmitting = false.obs; @override void onInit() { @@ -94,6 +95,9 @@ class AddContactController extends GetxController { required String address, required String description, }) async { + if (isSubmitting.value) return; + isSubmitting.value = true; + final categoryId = categoriesMap[selectedCategory.value]; final bucketId = bucketsMap[selectedBucket.value]; final projectIds = selectedProjects @@ -101,13 +105,13 @@ class AddContactController extends GetxController { .whereType() .toList(); - // === Required validations only for name, organization, and bucket === if (name.trim().isEmpty) { showAppSnackbar( title: "Missing Name", message: "Please enter the contact name.", type: SnackbarType.warning, ); + isSubmitting.value = false; return; } @@ -117,6 +121,7 @@ class AddContactController extends GetxController { message: "Please enter the organization name.", type: SnackbarType.warning, ); + isSubmitting.value = false; return; } @@ -126,10 +131,10 @@ class AddContactController extends GetxController { message: "Please select a bucket.", type: SnackbarType.warning, ); + isSubmitting.value = false; return; } - // === Build body (include optional fields if available) === try { final tagObjects = enteredTags.map((tagName) { final tagId = tagsMap[tagName]; @@ -182,6 +187,8 @@ class AddContactController extends GetxController { message: "Something went wrong", type: SnackbarType.error, ); + } finally { + isSubmitting.value = false; } } diff --git a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart index d5c37cb..72ae8c0 100644 --- a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart @@ -137,7 +137,6 @@ class _CommentTaskBottomSheetState extends State onCancel: () => Navigator.of(context).pop(), onSubmit: _submitComment, isSubmitting: controller.isLoading.value, - submitText: 'Comment', bottomContent: _buildCommentsSection(), child: Form( // moved to last diff --git a/lib/model/dailyTaskPlaning/create_task_botom_sheet.dart b/lib/model/dailyTaskPlaning/create_task_botom_sheet.dart index 650087e..4c959de 100644 --- a/lib/model/dailyTaskPlaning/create_task_botom_sheet.dart +++ b/lib/model/dailyTaskPlaning/create_task_botom_sheet.dart @@ -4,8 +4,7 @@ import 'package:marco/controller/task_planing/add_task_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; - - +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; void showCreateTaskBottomSheet({ required String workArea, @@ -27,197 +26,120 @@ void showCreateTaskBottomSheet({ Get.bottomSheet( StatefulBuilder( builder: (context, setState) { - return LayoutBuilder( - builder: (context, constraints) { - final isLarge = constraints.maxWidth > 600; - final horizontalPadding = - isLarge ? constraints.maxWidth * 0.2 : 16.0; + return BaseBottomSheet( + title: "Create Task", + onCancel: () => Get.back(), + onSubmit: () async { + final plannedValue = + int.tryParse(plannedTaskController.text.trim()) ?? 0; + final comment = descriptionController.text.trim(); + final selectedCategoryId = controller.selectedCategoryId.value; - return // Inside showManageTaskBottomSheet... + if (selectedCategoryId == null) { + showAppSnackbar( + title: "error", + message: "Please select a work category!", + type: SnackbarType.error, + ); + return; + } - SafeArea( - child: Material( - color: Colors.white, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(20)), - child: Container( - constraints: const BoxConstraints(maxHeight: 760), - padding: EdgeInsets.fromLTRB( - horizontalPadding, 12, horizontalPadding, 24), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + final success = await controller.createTask( + parentTaskId: parentTaskId, + plannedTask: plannedValue, + comment: comment, + workAreaId: workAreaId, + activityId: activityId, + categoryId: selectedCategoryId, + ); + + if (success) { + Get.back(); + Future.delayed(const Duration(milliseconds: 300), () { + onSubmit(); + showAppSnackbar( + title: "Success", + message: "Task created successfully!", + type: SnackbarType.success, + ); + }); + } + }, + submitText: "Submit", + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _infoCardSection([ + _infoRowWithIcon( + Icons.workspaces, "Selected Work Area", workArea), + _infoRowWithIcon(Icons.list_alt, "Selected Activity", activity), + _infoRowWithIcon(Icons.check_circle_outline, "Completed Work", + completedWork), + ]), + const SizedBox(height: 16), + _sectionTitle(Icons.edit_calendar, "Planned Work"), + const SizedBox(height: 6), + _customTextField( + controller: plannedTaskController, + hint: "Enter planned work", + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + _sectionTitle(Icons.description_outlined, "Comment"), + const SizedBox(height: 6), + _customTextField( + controller: descriptionController, + hint: "Enter task description", + maxLines: 3, + ), + const SizedBox(height: 16), + _sectionTitle(Icons.category_outlined, "Selected Work Category"), + const SizedBox(height: 6), + Obx(() { + final categoryMap = controller.categoryIdNameMap; + final String selectedName = + controller.selectedCategoryId.value != null + ? (categoryMap[controller.selectedCategoryId.value!] ?? + 'Select Category') + : 'Select Category'; + + return Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: PopupMenuButton( + padding: EdgeInsets.zero, + onSelected: (val) { + controller.selectCategory(val); + onCategoryChanged(val); + }, + itemBuilder: (context) => categoryMap.entries + .map((entry) => PopupMenuItem( + value: entry.key, + child: Text(entry.value), + )) + .toList(), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - Center( - child: MyText.titleLarge( - "Create Task", - fontWeight: 700, - ), - ), - const SizedBox(height: 20), - _infoCardSection([ - _infoRowWithIcon( - Icons.workspaces, "Selected Work Area", workArea), - _infoRowWithIcon( - Icons.list_alt, "Selected Activity", activity), - _infoRowWithIcon(Icons.check_circle_outline, - "Completed Work", completedWork), - ]), - const SizedBox(height: 16), - _sectionTitle(Icons.edit_calendar, "Planned Work"), - const SizedBox(height: 6), - _customTextField( - controller: plannedTaskController, - hint: "Enter planned work", - keyboardType: TextInputType.number, - ), - const SizedBox(height: 16), - _sectionTitle(Icons.description_outlined, "Comment"), - const SizedBox(height: 6), - _customTextField( - controller: descriptionController, - hint: "Enter task description", - maxLines: 3, - ), - const SizedBox(height: 16), - _sectionTitle( - Icons.category_outlined, "Selected Work Category"), - const SizedBox(height: 6), - Obx(() { - final categoryMap = controller.categoryIdNameMap; - final String selectedName = - controller.selectedCategoryId.value != null - ? (categoryMap[controller - .selectedCategoryId.value!] ?? - 'Select Category') - : 'Select Category'; - - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 14), - decoration: BoxDecoration( - color: Colors.grey.shade100, - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - ), - child: PopupMenuButton( - padding: EdgeInsets.zero, - onSelected: (val) { - controller.selectCategory(val); - onCategoryChanged(val); - }, - itemBuilder: (context) => categoryMap.entries - .map( - (entry) => PopupMenuItem( - value: entry.key, - child: Text(entry.value), - ), - ) - .toList(), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - selectedName, - style: const TextStyle( - fontSize: 14, color: Colors.black87), - ), - const Icon(Icons.arrow_drop_down), - ], - ), - ), - ); - }), - const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => Get.back(), - icon: const Icon(Icons.close, size: 18), - label: MyText.bodyMedium("Cancel", - fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.grey), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: () async { - final plannedValue = int.tryParse( - plannedTaskController.text.trim()) ?? - 0; - final comment = - descriptionController.text.trim(); - final selectedCategoryId = - controller.selectedCategoryId.value; - if (selectedCategoryId == null) { - showAppSnackbar( - title: "error", - message: "Please select a work category!", - type: SnackbarType.error, - ); - return; - } - - final success = await controller.createTask( - parentTaskId: parentTaskId, - plannedTask: plannedValue, - comment: comment, - workAreaId: workAreaId, - activityId: activityId, - categoryId: selectedCategoryId, - ); - - if (success) { - Get.back(); - Future.delayed( - const Duration(milliseconds: 300), () { - onSubmit(); - showAppSnackbar( - title: "Success", - message: "Task created successfully!", - type: SnackbarType.success, - ); - }); - } - }, - icon: const Icon(Icons.check, size: 18), - label: MyText.bodyMedium("Submit", - color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueAccent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - ), - ), - ], + Text( + selectedName, + style: const TextStyle( + fontSize: 14, color: Colors.black87), ), + const Icon(Icons.arrow_drop_down), ], ), ), - ), - ), - ); - }, + ); + }), + ], + ), ); }, ), diff --git a/lib/model/directory/add_contact_bottom_sheet.dart b/lib/model/directory/add_contact_bottom_sheet.dart index bb8761a..c8ef83a 100644 --- a/lib/model/directory/add_contact_bottom_sheet.dart +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:get/get.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'; @@ -8,6 +8,7 @@ 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; @@ -18,25 +19,24 @@ class AddContactBottomSheet extends StatefulWidget { } class _AddContactBottomSheetState extends State { - // Controllers and state - final AddContactController controller = Get.put(AddContactController()); + final 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 nameCtrl = TextEditingController(); + final orgCtrl = TextEditingController(); + final addrCtrl = TextEditingController(); + final descCtrl = TextEditingController(); + final tagCtrl = TextEditingController(); + final showAdvanced = false.obs; - final emailControllers = [].obs; - final emailLabels = [].obs; - final phoneControllers = [].obs; - final phoneLabels = [].obs; - - // For required bucket validation (new) final bucketError = ''.obs; + final emailCtrls = [].obs; + final emailLabels = [].obs; + + final phoneCtrls = [].obs; + final phoneLabels = [].obs; + @override void initState() { super.initState(); @@ -47,34 +47,40 @@ class _AddContactBottomSheetState extends State { 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 + 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))); + : 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 + + phoneCtrls.assignAll(c.contactPhones.isEmpty ? [TextEditingController()] - : c.contactPhones.map((p) => TextEditingController(text: p.phoneNumber))); + : 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)); + + 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 categoryName = c.contactCategory?.name; - if (categoryName != null) controller.selectedCategory.value = categoryName; + final category = c.contactCategory?.name; + + if (category != null) controller.selectedCategory.value = category; + if (projectIds != null) { controller.selectedProjects.assignAll( - projectIds // + projectIds .map((id) => controller.projectsMap.entries .firstWhereOrNull((e) => e.value == id) ?.key) @@ -82,6 +88,7 @@ class _AddContactBottomSheetState extends State { .toList(), ); } + if (bucketId != null) { final name = controller.bucketsMap.entries .firstWhereOrNull((e) => e.value == bucketId) @@ -91,32 +98,26 @@ class _AddContactBottomSheetState extends State { } }); } else { - emailControllers.add(TextEditingController()); + emailCtrls.add(TextEditingController()); emailLabels.add('Office'.obs); - phoneControllers.add(TextEditingController()); + phoneCtrls.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(); - } + nameCtrl.dispose(); + orgCtrl.dispose(); + addrCtrl.dispose(); + descCtrl.dispose(); + tagCtrl.dispose(); + emailCtrls.forEach((c) => c.dispose()); + phoneCtrls.forEach((c) => c.dispose()); Get.delete(); super.dispose(); } - // --- COMMON WIDGETS --- InputDecoration _inputDecoration(String hint) => InputDecoration( hintText: hint, hintStyle: MyTextStyle.bodySmall(xMuted: true), @@ -134,126 +135,150 @@ class _AddContactBottomSheetState extends State { borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + 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( + Widget _textField(String label, TextEditingController ctrl, + {bool required = false, int maxLines = 1}) { + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelMedium(label), - MySpacing.height(8), - _popupSelector( - hint: "Label", - selectedValue: selectedLabel, - options: options, - ), - ], - ), + 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, ), - 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, - }) { + 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, (index) { + children: List.generate(ctrls.length, (i) { 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; - } + 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); }, - ) - : null, + ), + ), + ], ), ); }), @@ -261,85 +286,18 @@ class _AddContactBottomSheetState extends State { }); } - 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() { + Widget _tagInput() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: 48, child: TextField( - controller: tagTextController, + controller: tagCtrl, onChanged: controller.filterSuggestions, - onSubmitted: (value) { - controller.addEnteredTag(value); - tagTextController.clear(); + onSubmitted: (v) { + controller.addEnteredTag(v); + tagCtrl.clear(); controller.clearSuggestions(); }, decoration: _inputDecoration("Start typing to add tags"), @@ -353,19 +311,21 @@ class _AddContactBottomSheetState extends State { color: Colors.white, border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(8), - boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4)], + 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]; + itemBuilder: (_, i) { + final suggestion = controller.filteredSuggestions[i]; return ListTile( dense: true, title: Text(suggestion), onTap: () { controller.addEnteredTag(suggestion); - tagTextController.clear(); + tagCtrl.clear(); controller.clearSuggestions(); }, ); @@ -386,349 +346,154 @@ class _AddContactBottomSheetState extends State { ); } - // ---- 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, - ), - ], - ); + void _handleSubmit() { + bool valid = formKey.currentState?.validate() ?? false; - // -- 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(); - }, - ); - }, - )), - ], - ); + if (controller.selectedBucket.value.isEmpty) { + bucketError.value = "Bucket is required"; + valid = false; + } else { + bucketError.value = ""; + } - // 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), - ), - ), - ), - ], - ); + if (!valid) return; - // 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), - ], - ); - }), - ), + 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(), ); } - // --- 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( + + 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("Select Bucket"), + MySpacing.height(8), + Stack( + children: [ + _popupSelector(controller.selectedBucket, controller.buckets, + "Select 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: [ - _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), - ), - ), - ), - ), + MyText.labelLarge("Advanced Details (Optional)", + fontWeight: 600), + Icon(showAdvanced.value + ? Icons.expand_less + : Icons.expand_more), ], ), - 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(), - ], - ), - ), - ), + )), + 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, "Select 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()), + ], ), ), );