From e4165f2ee868628250742ebbdc53aa75dd4dec0b Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 1 Oct 2025 15:47:21 +0530 Subject: [PATCH] added add new project in projectselection --- .../expense/add_expense_bottom_sheet.dart | 747 ++++++++++-------- 1 file changed, 399 insertions(+), 348 deletions(-) diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index bd21ef5..29f52e8 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -19,10 +19,7 @@ Future showAddExpenseBottomSheet({ Map? existingExpense, }) { return Get.bottomSheet( - _AddExpenseBottomSheet( - isEdit: isEdit, - existingExpense: existingExpense, - ), + _AddExpenseBottomSheet(isEdit: isEdit, existingExpense: existingExpense), isScrollControlled: true, ); } @@ -49,95 +46,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { final GlobalKey _expenseTypeDropdownKey = GlobalKey(); final GlobalKey _paymentModeDropdownKey = GlobalKey(); - /// Show employee list - Future _showEmployeeList() async { - await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (_) => ReusableEmployeeSelectorBottomSheet( - searchController: controller.employeeSearchController, - searchResults: controller.employeeSearchResults, - isSearching: controller.isSearchingEmployees, - onSearch: controller.searchEmployees, - onSelect: (emp) => controller.selectedPaidBy.value = emp, - ), - ); - - controller.employeeSearchController.clear(); - controller.employeeSearchResults.clear(); - } - - /// Generic option list - Future _showOptionList( - List options, - String Function(T) getLabel, - ValueChanged onSelected, - GlobalKey triggerKey, - ) async { - final RenderBox button = - triggerKey.currentContext!.findRenderObject() as RenderBox; - final RenderBox overlay = - Overlay.of(context).context.findRenderObject() as RenderBox; - final position = button.localToGlobal(Offset.zero, ancestor: overlay); - - final selected = await showMenu( - context: context, - position: RelativeRect.fromLTRB( - position.dx, - position.dy + button.size.height, - overlay.size.width - position.dx - button.size.width, - 0, - ), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - items: options - .map((opt) => PopupMenuItem( - value: opt, - child: Text(getLabel(opt)), - )) - .toList(), - ); - - if (selected != null) onSelected(selected); - } - - /// Validate required selections - bool _validateSelections() { - if (controller.selectedProject.value.isEmpty) { - _showError("Please select a project"); - return false; - } - if (controller.selectedExpenseType.value == null) { - _showError("Please select an expense type"); - return false; - } - if (controller.selectedPaymentMode.value == null) { - _showError("Please select a payment mode"); - return false; - } - if (controller.selectedPaidBy.value == null) { - _showError("Please select a person who paid"); - return false; - } - if (controller.attachments.isEmpty && - controller.existingAttachments.isEmpty) { - _showError("Please attach at least one document"); - return false; - } - return true; - } - - void _showError(String msg) { - showAppSnackbar( - title: "Error", - message: msg, - type: SnackbarType.error, - ); - } - @override Widget build(BuildContext context) { return Obx( @@ -147,148 +55,44 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { title: widget.isEdit ? "Edit Expense" : "Add Expense", isSubmitting: controller.isSubmitting.value, onCancel: Get.back, - onSubmit: () { - if (_formKey.currentState!.validate() && _validateSelections()) { - controller.submitOrUpdateExpense(); - } else { - _showError("Please fill all required fields correctly"); - } - }, + onSubmit: _handleSubmit, child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 👇 Add New Project Button - Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - onPressed: () async { - await Get.bottomSheet( - const CreateProjectBottomSheet(), - isScrollControlled: true, - ); - - // 🔄 Refresh project list after adding new project (optional) - await controller.fetchGlobalProjects(); - }, - icon: const Icon(Icons.add, color: Colors.blue), - label: const Text( - "Add Project", - style: TextStyle( - color: Colors.blue, - fontWeight: FontWeight.w600, - ), - ), - ), - ), + _buildCreateProjectButton(), + _buildProjectDropdown(), _gap(), - - _buildDropdownField( - icon: Icons.work_outline, - title: "Project", - requiredField: true, - value: controller.selectedProject.value.isEmpty - ? "Select Project" - : controller.selectedProject.value, - onTap: () => _showOptionList( - controller.globalProjects.toList(), - (p) => p, - (val) => controller.selectedProject.value = val, - _projectDropdownKey, - ), - dropdownKey: _projectDropdownKey, - ), - _gap(), - - _buildDropdownField( - icon: Icons.category_outlined, - title: "Expense Type", - requiredField: true, - value: controller.selectedExpenseType.value?.name ?? - "Select Expense Type", - onTap: () => _showOptionList( - controller.expenseTypes.toList(), - (e) => e.name, - (val) => controller.selectedExpenseType.value = val, - _expenseTypeDropdownKey, - ), - dropdownKey: _expenseTypeDropdownKey, - ), - - // Persons if required + _buildExpenseTypeDropdown(), if (controller.selectedExpenseType.value?.noOfPersonsRequired == true) ...[ _gap(), - _buildTextFieldSection( + _buildNumberField( icon: Icons.people_outline, title: "No. of Persons", controller: controller.noOfPersonsController, hint: "Enter No. of Persons", - keyboardType: TextInputType.number, validator: Validators.requiredField, ), ], _gap(), - - _buildDropdownField( - icon: Icons.payment, - title: "Payment Mode", - requiredField: true, - value: controller.selectedPaymentMode.value?.name ?? - "Select Payment Mode", - onTap: () => _showOptionList( - controller.paymentModes.toList(), - (p) => p.name, - (val) => controller.selectedPaymentMode.value = val, - _paymentModeDropdownKey, - ), - dropdownKey: _paymentModeDropdownKey, - ), + _buildPaymentModeDropdown(), _gap(), - _buildPaidBySection(), _gap(), - - _buildTextFieldSection( - icon: Icons.currency_rupee, - title: "Amount", - controller: controller.amountController, - hint: "Enter Amount", - keyboardType: TextInputType.number, - validator: (v) => Validators.isNumeric(v ?? "") - ? null - : "Enter valid amount", - ), + _buildAmountField(), _gap(), - - _buildTextFieldSection( - icon: Icons.store_mall_directory_outlined, - title: "Supplier Name/Transporter Name/Other", - controller: controller.supplierController, - hint: "Enter Supplier Name/Transporter Name or Other", - validator: Validators.nameValidator, - ), + _buildSupplierField(), _gap(), - _buildTransactionDateField(), _gap(), - _buildTransactionIdField(), _gap(), _buildLocationField(), _gap(), - _buildAttachmentsSection(), _gap(), - - _buildTextFieldSection( - icon: Icons.description_outlined, - title: "Description", - controller: controller.descriptionController, - hint: "Enter Description", - maxLines: 3, - validator: Validators.requiredField, - ), + _buildDescriptionField(), ], ), ), @@ -297,6 +101,102 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ); } + /// 🟦 UI SECTION BUILDERS + + Widget _buildCreateProjectButton() { + return Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () async { + await Get.bottomSheet(const CreateProjectBottomSheet(), + isScrollControlled: true); + await controller.fetchGlobalProjects(); + }, + icon: const Icon(Icons.add, color: Colors.blue), + label: const Text( + "Create Project", + style: TextStyle(color: Colors.blue, fontWeight: FontWeight.w600), + ), + ), + ); + } + + Widget _buildProjectDropdown() { + return _buildDropdownField( + icon: Icons.work_outline, + title: "Project", + requiredField: true, + value: controller.selectedProject.value.isEmpty + ? "Select Project" + : controller.selectedProject.value, + onTap: _showProjectSelector, + dropdownKey: _projectDropdownKey, + ); + } + + Widget _buildExpenseTypeDropdown() { + return _buildDropdownField( + icon: Icons.category_outlined, + title: "Expense Type", + requiredField: true, + value: + controller.selectedExpenseType.value?.name ?? "Select Expense Type", + onTap: () => _showOptionList( + controller.expenseTypes.toList(), + (e) => e.name, + (val) => controller.selectedExpenseType.value = val, + _expenseTypeDropdownKey, + ), + dropdownKey: _expenseTypeDropdownKey, + ); + } + + Widget _buildPaymentModeDropdown() { + return _buildDropdownField( + icon: Icons.payment, + title: "Payment Mode", + requiredField: true, + value: + controller.selectedPaymentMode.value?.name ?? "Select Payment Mode", + onTap: () => _showOptionList( + controller.paymentModes.toList(), + (p) => p.name, + (val) => controller.selectedPaymentMode.value = val, + _paymentModeDropdownKey, + ), + dropdownKey: _paymentModeDropdownKey, + ); + } + + Widget _buildPaidBySection() { + return _buildTileSelector( + icon: Icons.person_outline, + title: "Paid By", + required: true, + displayText: controller.selectedPaidBy.value == null + ? "Select Paid By" + : '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}', + onTap: _showEmployeeList, + ); + } + + Widget _buildAmountField() => _buildNumberField( + icon: Icons.currency_rupee, + title: "Amount", + controller: controller.amountController, + hint: "Enter Amount", + validator: (v) => + Validators.isNumeric(v ?? "") ? null : "Enter valid amount", + ); + + Widget _buildSupplierField() => _buildTextField( + icon: Icons.store_mall_directory_outlined, + title: "Supplier Name/Transporter Name/Other", + controller: controller.supplierController, + hint: "Enter Supplier Name/Transporter Name or Other", + validator: Validators.nameValidator, + ); + Widget _buildTransactionIdField() { final paymentMode = controller.selectedPaymentMode.value?.name.toLowerCase() ?? ''; @@ -304,126 +204,32 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { paymentMode != 'cash' && paymentMode != 'cheque'; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionTitle( - icon: Icons.confirmation_number_outlined, - title: "Transaction ID", - requiredField: isRequired, - ), - MySpacing.height(6), - CustomTextField( - controller: controller.transactionIdController, - hint: "Enter Transaction ID", - validator: (v) { - if (isRequired) { - if (v == null || v.isEmpty) { - return "Transaction ID is required for this payment mode"; - } - return Validators.transactionIdValidator(v); - } - return null; - }, - ), - ], - ); - } - - Widget _gap([double h = 16]) => MySpacing.height(h); - - Widget _buildDropdownField({ - required IconData icon, - required String title, - required bool requiredField, - required String value, - required VoidCallback onTap, - required GlobalKey dropdownKey, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionTitle(icon: icon, title: title, requiredField: requiredField), - MySpacing.height(6), - DropdownTile(key: dropdownKey, title: value, onTap: onTap), - ], - ); - } - - Widget _buildTextFieldSection({ - required IconData icon, - required String title, - required TextEditingController controller, - String? hint, - TextInputType? keyboardType, - FormFieldValidator? validator, - int maxLines = 1, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionTitle( - icon: icon, title: title, requiredField: validator != null), - MySpacing.height(6), - CustomTextField( - controller: controller, - hint: hint ?? "", - keyboardType: keyboardType ?? TextInputType.text, - validator: validator, - maxLines: maxLines, - ), - ], - ); - } - - Widget _buildPaidBySection() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SectionTitle( - icon: Icons.person_outline, title: "Paid By", requiredField: true), - MySpacing.height(6), - GestureDetector( - onTap: _showEmployeeList, - child: TileContainer( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - controller.selectedPaidBy.value == null - ? "Select Paid By" - : '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}', - style: const TextStyle(fontSize: 14), - ), - const Icon(Icons.arrow_drop_down, size: 22), - ], - ), - ), - ), - ], + return _buildTextField( + icon: Icons.confirmation_number_outlined, + title: "Transaction ID", + controller: controller.transactionIdController, + hint: "Enter Transaction ID", + validator: (v) { + if (isRequired) { + if (v == null || v.isEmpty) + return "Transaction ID is required for this payment mode"; + return Validators.transactionIdValidator(v); + } + return null; + }, + requiredField: isRequired, ); } Widget _buildTransactionDateField() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SectionTitle( - icon: Icons.calendar_today, - title: "Transaction Date", - requiredField: true), - MySpacing.height(6), - GestureDetector( - onTap: () => controller.pickTransactionDate(context), - child: AbsorbPointer( - child: CustomTextField( - controller: controller.transactionDateController, - hint: "Select Transaction Date", - validator: Validators.requiredField, - ), - ), - ), - ], + return _buildTileSelector( + icon: Icons.calendar_today, + title: "Transaction Date", + required: true, + displayText: controller.transactionDateController.text.isEmpty + ? "Select Transaction Date" + : controller.transactionDateController.text, + onTap: () => controller.pickTransactionDate(context), ); } @@ -467,40 +273,285 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SectionTitle( - icon: Icons.attach_file, title: "Attachments", requiredField: true), + icon: Icons.attach_file, + title: "Attachments", + requiredField: true, + ), MySpacing.height(6), AttachmentsSection( attachments: controller.attachments, existingAttachments: controller.existingAttachments, onRemoveNew: controller.removeAttachment, - onRemoveExisting: (item) async { - await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => ConfirmDialog( - title: "Remove Attachment", - message: "Are you sure you want to remove this attachment?", - confirmText: "Remove", - icon: Icons.delete, - confirmColor: Colors.redAccent, - onConfirm: () async { - final index = controller.existingAttachments.indexOf(item); - if (index != -1) { - controller.existingAttachments[index]['isActive'] = false; - controller.existingAttachments.refresh(); - } - showAppSnackbar( - title: 'Removed', - message: 'Attachment has been removed.', - type: SnackbarType.success, - ); - }, - ), - ); - }, + onRemoveExisting: _confirmRemoveAttachment, onAdd: controller.pickAttachments, ), ], ); } + + Widget _buildDescriptionField() => _buildTextField( + icon: Icons.description_outlined, + title: "Description", + controller: controller.descriptionController, + hint: "Enter Description", + maxLines: 3, + validator: Validators.requiredField, + ); + + /// 🟩 COMMON HELPERS + + Widget _gap([double h = 16]) => MySpacing.height(h); + + Widget _buildDropdownField({ + required IconData icon, + required String title, + required bool requiredField, + required String value, + required VoidCallback onTap, + required GlobalKey dropdownKey, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle(icon: icon, title: title, requiredField: requiredField), + MySpacing.height(6), + DropdownTile(key: dropdownKey, title: value, onTap: onTap), + ], + ); + } + + Widget _buildTextField({ + required IconData icon, + required String title, + required TextEditingController controller, + String? hint, + FormFieldValidator? validator, + bool requiredField = true, + int maxLines = 1, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle(icon: icon, title: title, requiredField: requiredField), + MySpacing.height(6), + CustomTextField( + controller: controller, + hint: hint ?? "", + validator: validator, + maxLines: maxLines, + ), + ], + ); + } + + Widget _buildNumberField({ + required IconData icon, + required String title, + required TextEditingController controller, + String? hint, + FormFieldValidator? validator, + }) { + return _buildTextField( + icon: icon, + title: title, + controller: controller, + hint: hint, + validator: validator, + ); + } + + Widget _buildTileSelector({ + required IconData icon, + required String title, + required String displayText, + required VoidCallback onTap, + bool required = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle(icon: icon, title: title, requiredField: required), + MySpacing.height(6), + GestureDetector( + onTap: onTap, + child: TileContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(displayText, style: const TextStyle(fontSize: 14)), + const Icon(Icons.arrow_drop_down, size: 22), + ], + ), + ), + ), + ], + ); + } + + /// 🧰 LOGIC HELPERS + + Future _showProjectSelector() async { + final sortedProjects = controller.globalProjects.toList() + ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + + const specialOption = 'Create New Project'; + final displayList = [...sortedProjects, specialOption]; + + final selected = await showMenu( + context: context, + position: _getPopupMenuPosition(_projectDropdownKey), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + items: displayList.map((opt) { + final isSpecial = opt == specialOption; + return PopupMenuItem( + value: opt, + child: isSpecial + ? Row( + children: const [ + Icon(Icons.add, color: Colors.blue), + SizedBox(width: 8), + Text( + specialOption, + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.blue, + ), + ), + ], + ) + : Text( + opt, + style: const TextStyle( + fontWeight: FontWeight.normal, + color: Colors.black, + ), + ), + ); + }).toList(), + ); + + if (selected == null) return; + if (selected == specialOption) { + controller.selectedProject.value = specialOption; + await Get.bottomSheet(const CreateProjectBottomSheet(), + isScrollControlled: true); + await controller.fetchGlobalProjects(); + controller.selectedProject.value = ""; + } else { + controller.selectedProject.value = selected; + } + } + + Future _showEmployeeList() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => ReusableEmployeeSelectorBottomSheet( + searchController: controller.employeeSearchController, + searchResults: controller.employeeSearchResults, + isSearching: controller.isSearchingEmployees, + onSearch: controller.searchEmployees, + onSelect: (emp) => controller.selectedPaidBy.value = emp, + ), + ); + controller.employeeSearchController.clear(); + controller.employeeSearchResults.clear(); + } + + Future _confirmRemoveAttachment(item) async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => ConfirmDialog( + title: "Remove Attachment", + message: "Are you sure you want to remove this attachment?", + confirmText: "Remove", + icon: Icons.delete, + confirmColor: Colors.redAccent, + onConfirm: () async { + final index = controller.existingAttachments.indexOf(item); + if (index != -1) { + controller.existingAttachments[index]['isActive'] = false; + controller.existingAttachments.refresh(); + } + showAppSnackbar( + title: 'Removed', + message: 'Attachment has been removed.', + type: SnackbarType.success, + ); + }, + ), + ); + } + + Future _showOptionList( + List options, + String Function(T) getLabel, + ValueChanged onSelected, + GlobalKey triggerKey, + ) async { + final selected = await showMenu( + context: context, + position: _getPopupMenuPosition(triggerKey), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + items: options + .map((opt) => PopupMenuItem( + value: opt, + child: Text(getLabel(opt)), + )) + .toList(), + ); + if (selected != null) onSelected(selected); + } + + RelativeRect _getPopupMenuPosition(GlobalKey key) { + final RenderBox button = + key.currentContext!.findRenderObject() as RenderBox; + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + final position = button.localToGlobal(Offset.zero, ancestor: overlay); + return RelativeRect.fromLTRB( + position.dx, + position.dy + button.size.height, + overlay.size.width - position.dx - button.size.width, + 0, + ); + } + + bool _validateSelections() { + if (controller.selectedProject.value.isEmpty) { + return _error("Please select a project"); + } + if (controller.selectedExpenseType.value == null) { + return _error("Please select an expense type"); + } + if (controller.selectedPaymentMode.value == null) { + return _error("Please select a payment mode"); + } + if (controller.selectedPaidBy.value == null) { + return _error("Please select a person who paid"); + } + if (controller.attachments.isEmpty && + controller.existingAttachments.isEmpty) { + return _error("Please attach at least one document"); + } + return true; + } + + bool _error(String msg) { + showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error); + return false; + } + + void _handleSubmit() { + if (_formKey.currentState!.validate() && _validateSelections()) { + controller.submitOrUpdateExpense(); + } else { + _error("Please fill all required fields correctly"); + } + } }