From 1a6ad4edfc4ec81dc4689d6165493a1173e94241 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 6 Nov 2025 12:21:53 +0530 Subject: [PATCH] made chnages in request payment --- .../add_payment_request_controller.dart | 318 ++++++----- lib/helpers/services/api_endpoints.dart | 2 + lib/helpers/services/api_service.dart | 59 +- .../add_payment_request_bottom_sheet.dart | 539 ++++++++++-------- lib/view/finance/finance_screen.dart | 2 +- pubspec.yaml | 1 + 6 files changed, 531 insertions(+), 390 deletions(-) diff --git a/lib/controller/finance/add_payment_request_controller.dart b/lib/controller/finance/add_payment_request_controller.dart index 432a245..3f20f56 100644 --- a/lib/controller/finance/add_payment_request_controller.dart +++ b/lib/controller/finance/add_payment_request_controller.dart @@ -1,3 +1,4 @@ +// payment_request_controller.dart import 'dart:io'; import 'dart:convert'; import 'package:get/get.dart'; @@ -5,6 +6,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:mime/mime.dart'; +import 'package:intl/intl.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/app_logger.dart'; @@ -14,49 +16,38 @@ import 'package:marco/model/finance/expense_category_model.dart'; import 'package:marco/model/finance/currency_list_model.dart'; class PaymentRequestController extends GetxController { - // ───────────────────────────────────────────── - // 🔹 Loading States - // ───────────────────────────────────────────── + // Loading States final isLoadingPayees = false.obs; final isLoadingCategories = false.obs; final isLoadingCurrencies = false.obs; final isProcessingAttachment = false.obs; + final isSubmitting = false.obs; - // ───────────────────────────────────────────── - // 🔹 Data Lists - // ───────────────────────────────────────────── + // Data Lists final payees = [].obs; final categories = [].obs; final currencies = [].obs; - final globalProjects = [].obs; + final globalProjects = >[].obs; - // ───────────────────────────────────────────── - // 🔹 Selected Values - // ───────────────────────────────────────────── + // Selected Values + final selectedProject = Rx?>(null); final selectedCategory = Rx(null); final selectedPayee = ''.obs; final selectedCurrency = Rx(null); - final selectedProject = ''.obs; final isAdvancePayment = false.obs; + final selectedDueDate = Rx(null); - // ───────────────────────────────────────────── - // 🔹 Text Controllers - // ───────────────────────────────────────────── + // Text Controllers final titleController = TextEditingController(); final dueDateController = TextEditingController(); final amountController = TextEditingController(); final descriptionController = TextEditingController(); - // ───────────────────────────────────────────── - // 🔹 Attachments - // ───────────────────────────────────────────── + // Attachments final attachments = [].obs; final existingAttachments = >[].obs; final ImagePicker _picker = ImagePicker(); - // ───────────────────────────────────────────── - // 🔹 Lifecycle - // ───────────────────────────────────────────── @override void onInit() { super.onInit(); @@ -73,153 +64,110 @@ class PaymentRequestController extends GetxController { super.onClose(); } - // ───────────────────────────────────────────── - // 🔹 Master Data Fetch - // ───────────────────────────────────────────── + /// Fetch all master data concurrently Future fetchAllMasterData() async { await Future.wait([ - fetchPayees(), - fetchExpenseCategories(), - fetchCurrencies(), + _fetchData( + payees, ApiService.getExpensePaymentRequestPayeeApi, isLoadingPayees), + _fetchData(categories, ApiService.getMasterExpenseCategoriesApi, + isLoadingCategories), + _fetchData( + currencies, ApiService.getMasterCurrenciesApi, isLoadingCurrencies), ]); } - Future fetchPayees() async { + /// Generic fetch handler + Future _fetchData( + RxList list, Future Function() apiCall, RxBool loader) async { try { - isLoadingPayees.value = true; - final response = await ApiService.getExpensePaymentRequestPayeeApi(); + loader.value = true; + final response = await apiCall(); if (response != null && response.data.isNotEmpty) { - payees.value = response.data; + list.value = response.data; } else { - payees.clear(); + list.clear(); } } catch (e) { - logSafe("Error fetching payees: $e", level: LogLevel.error); + logSafe("Error fetching data: $e", level: LogLevel.error); + list.clear(); } finally { - isLoadingPayees.value = false; - } - } - - Future fetchExpenseCategories() async { - try { - isLoadingCategories.value = true; - final response = await ApiService.getMasterExpenseCategoriesApi(); - if (response != null && response.data.isNotEmpty) { - categories.value = response.data; - } else { - categories.clear(); - } - } catch (e) { - logSafe("Error fetching categories: $e", level: LogLevel.error); - } finally { - isLoadingCategories.value = false; - } - } - - Future fetchCurrencies() async { - try { - isLoadingCurrencies.value = true; - final response = await ApiService.getMasterCurrenciesApi(); - if (response != null && response.data.isNotEmpty) { - currencies.value = response.data; - } else { - currencies.clear(); - } - } catch (e) { - logSafe("Error fetching currencies: $e", level: LogLevel.error); - } finally { - isLoadingCurrencies.value = false; + loader.value = false; } } + /// Fetch projects Future fetchGlobalProjects() async { try { final response = await ApiService.getGlobalProjects(); - if (response != null && response.isNotEmpty) { - globalProjects.value = response - .map((e) => e['name']?.toString().trim() ?? '') - .where((name) => name.isNotEmpty) - .toList(); - } else { - globalProjects.clear(); - } + globalProjects.value = (response ?? []) + .map>((e) => { + 'id': e['id']?.toString() ?? '', + 'name': e['name']?.toString().trim() ?? '', + }) + .where((p) => p['id']!.isNotEmpty && p['name']!.isNotEmpty) + .toList(); } catch (e) { logSafe("Error fetching projects: $e", level: LogLevel.error); globalProjects.clear(); } } - // ───────────────────────────────────────────── - // 🔹 File / Image Pickers - // ───────────────────────────────────────────── + /// Pick due date + Future pickDueDate(BuildContext context) async { + final pickedDate = await showDatePicker( + context: context, + initialDate: selectedDueDate.value ?? DateTime.now(), + firstDate: DateTime(DateTime.now().year - 5), + lastDate: DateTime(DateTime.now().year + 5), + ); - /// 📂 Pick **any type of attachment** (no extension restriction) - Future pickAttachments() async { - try { - final result = await FilePicker.platform.pickFiles( - type: FileType.any, // ✅ No restriction on type - allowMultiple: true, - ); - if (result != null && result.paths.isNotEmpty) { - attachments.addAll(result.paths.whereType().map(File.new)); - } - } catch (e) { - _errorSnackbar("Attachment error: $e"); + if (pickedDate != null) { + selectedDueDate.value = pickedDate; + dueDateController.text = DateFormat('dd MMM yyyy').format(pickedDate); } } - /// 📸 Pick from camera and auto add timestamp - Future pickFromCamera() async { + /// Generic file picker for multiple sources + Future pickAttachments( + {bool fromGallery = false, bool fromCamera = false}) async { try { - final pickedFile = await _picker.pickImage(source: ImageSource.camera); - if (pickedFile != null) { - isProcessingAttachment.value = true; - File imageFile = File(pickedFile.path); - File timestampedFile = - await TimestampImageHelper.addTimestamp(imageFile: imageFile); - attachments.add(timestampedFile); - attachments.refresh(); + if (fromCamera) { + final pickedFile = await _picker.pickImage(source: ImageSource.camera); + if (pickedFile != null) { + isProcessingAttachment.value = true; + final timestamped = await TimestampImageHelper.addTimestamp( + imageFile: File(pickedFile.path)); + attachments.add(timestamped); + } + } else if (fromGallery) { + final pickedFile = await _picker.pickImage(source: ImageSource.gallery); + if (pickedFile != null) attachments.add(File(pickedFile.path)); + } else { + final result = await FilePicker.platform + .pickFiles(type: FileType.any, allowMultiple: true); + if (result != null && result.paths.isNotEmpty) + attachments.addAll(result.paths.whereType().map(File.new)); } + attachments.refresh(); } catch (e) { - _errorSnackbar("Camera error: $e"); + _errorSnackbar("Attachment error: $e"); } finally { isProcessingAttachment.value = false; } } - /// 🖼️ Pick from gallery - Future pickFromGallery() async { - try { - final pickedFile = await _picker.pickImage(source: ImageSource.gallery); - if (pickedFile != null) { - attachments.add(File(pickedFile.path)); - attachments.refresh(); - } - } catch (e) { - _errorSnackbar("Gallery error: $e"); - } - } - - // ───────────────────────────────────────────── - // 🔹 Value Selectors - // ───────────────────────────────────────────── - void selectProject(String project) => selectedProject.value = project; + /// Selection handlers + void selectProject(Map project) => + selectedProject.value = project; void selectCategory(ExpenseCategory category) => selectedCategory.value = category; void selectPayee(String payee) => selectedPayee.value = payee; void selectCurrency(Currency currency) => selectedCurrency.value = currency; - // ───────────────────────────────────────────── - // 🔹 Attachment Helpers - // ───────────────────────────────────────────── void addAttachment(File file) => attachments.add(file); void removeAttachment(File file) => attachments.remove(file); - void removeExistingAttachment(Map file) => - existingAttachments.remove(file); - // ───────────────────────────────────────────── - // 🔹 Payload Builder (for upload) - // ───────────────────────────────────────────── + /// Build attachment payload Future>> buildAttachmentPayload() async { final existingPayload = existingAttachments .map((e) => { @@ -234,27 +182,115 @@ class PaymentRequestController extends GetxController { }) .toList(); - final newPayload = await Future.wait( - attachments.map((file) async { - final bytes = await file.readAsBytes(); - return { - "fileName": file.path.split('/').last, - "base64Data": base64Encode(bytes), - "contentType": - lookupMimeType(file.path) ?? 'application/octet-stream', - "fileSize": await file.length(), - "description": "", - }; - }), - ); + final newPayload = await Future.wait(attachments.map((file) async { + final bytes = await file.readAsBytes(); + return { + "fileName": file.path.split('/').last, + "base64Data": base64Encode(bytes), + "contentType": lookupMimeType(file.path) ?? 'application/octet-stream', + "fileSize": await file.length(), + "description": "", + }; + })); return [...existingPayload, ...newPayload]; } - // ───────────────────────────────────────────── - // 🔹 Snackbar Helper - // ───────────────────────────────────────────── - void _errorSnackbar(String msg, [String title = "Error"]) { + /// Submit payment request (Project API style) + Future submitPaymentRequest() async { + if (isSubmitting.value) return false; + + try { + isSubmitting.value = true; + + // Validate form + if (!_validateForm()) return false; + + // Build attachment payload + final billAttachments = await buildAttachmentPayload(); + + final payload = { + "title": titleController.text.trim(), + "projectId": selectedProject.value?['id'] ?? '', + "expenseCategoryId": selectedCategory.value?.id ?? '', + "amount": double.tryParse(amountController.text.trim()) ?? 0, + "currencyId": selectedCurrency.value?.id ?? '', + "description": descriptionController.text.trim(), + "payee": selectedPayee.value, + "dueDate": selectedDueDate.value?.toIso8601String(), + "isAdvancePayment": isAdvancePayment.value, + "billAttachments": billAttachments.map((a) { + return { + "fileName": a['fileName'], + "fileSize": a['fileSize'], + "contentType": a['contentType'], + }; + }).toList(), + }; + + logSafe("💡 Submitting Payment Request: ${jsonEncode(payload)}"); + + final success = await ApiService.createExpensePaymentRequestApi( + title: payload['title'], + projectId: payload['projectId'], + expenseCategoryId: payload['expenseCategoryId'], + amount: payload['amount'], + currencyId: payload['currencyId'], + description: payload['description'], + payee: payload['payee'], + dueDate: selectedDueDate.value, + isAdvancePayment: payload['isAdvancePayment'], + billAttachments: billAttachments, + ); + + logSafe("💡 Payment Request API Response: $success"); + + if (success == true) { + logSafe("✅ Payment request created successfully."); + return true; + } else { + return _errorSnackbar("Failed to create payment request."); + } + } catch (e, st) { + logSafe("💥 Submit Payment Request Error: $e\n$st", + level: LogLevel.error); + return _errorSnackbar("Something went wrong. Please try again later."); + } finally { + isSubmitting.value = false; + } + } + + /// Form validation + bool _validateForm() { + if (selectedProject.value == null || + selectedProject.value!['id'].toString().isEmpty) + return _errorSnackbar("Please select a project"); + if (selectedCategory.value == null) + return _errorSnackbar("Please select a category"); + if (selectedPayee.value.isEmpty) + return _errorSnackbar("Please select a payee"); + if (selectedCurrency.value == null) + return _errorSnackbar("Please select currency"); + return true; + } + + bool _errorSnackbar(String msg, [String title = "Error"]) { showAppSnackbar(title: title, message: msg, type: SnackbarType.error); + return false; + } + + /// Clear form + void clearForm() { + titleController.clear(); + dueDateController.clear(); + amountController.clear(); + descriptionController.clear(); + selectedProject.value = null; + selectedCategory.value = null; + selectedPayee.value = ''; + selectedCurrency.value = null; + isAdvancePayment.value = false; + attachments.clear(); + existingAttachments.clear(); } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index cdf7cb8..0b5f88a 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -15,6 +15,8 @@ class ApiEndpoints { // Dashboard Module API Endpoints static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview"; + static const String createExpensePaymentRequest = + "/expense/payment-request/create"; static const String getDashboardProjectProgress = "/dashboard/progression"; static const String getDashboardTasks = "/dashboard/tasks"; static const String getDashboardTeams = "/dashboard/teams"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index d0fc2b6..dfc1b07 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -26,7 +26,6 @@ import 'package:marco/model/finance/expense_category_model.dart'; import 'package:marco/model/finance/currency_list_model.dart'; import 'package:marco/model/finance/payment_payee_request_model.dart'; - class ApiService { static const bool enableLogs = true; static const Duration extendedTimeout = Duration(seconds: 60); @@ -295,6 +294,64 @@ class ApiService { } } + /// Create Expense Payment Request (Project API style) + static Future createExpensePaymentRequestApi({ + required String title, + required String projectId, + required String expenseCategoryId, + required String currencyId, + required String payee, + required double amount, + DateTime? dueDate, + required String description, + required bool isAdvancePayment, + List> billAttachments = const [], + }) async { + const endpoint = ApiEndpoints.createExpensePaymentRequest; + + final body = { + "title": title, + "projectId": projectId, + "expenseCategoryId": expenseCategoryId, + "currencyId": currencyId, + "payee": payee, + "amount": amount, + "dueDate": dueDate?.toIso8601String(), + "description": description, + "isAdvancePayment": isAdvancePayment, + "billAttachments": billAttachments, + }; + + try { + final response = await _postRequest(endpoint, body); + if (response == null) { + logSafe("Create Payment Request failed: null response", + level: LogLevel.error); + return false; + } + + logSafe("Create Payment Request response status: ${response.statusCode}"); + logSafe("Create Payment Request response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Payment Request created successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to create Payment Request: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + return false; + } + } catch (e, stack) { + logSafe("Exception during createExpensePaymentRequestApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return false; + } + } + /// Get Master Currencies static Future getMasterCurrenciesApi() async { const endpoint = ApiEndpoints.getMasterCurrencies; diff --git a/lib/model/finance/add_payment_request_bottom_sheet.dart b/lib/model/finance/add_payment_request_bottom_sheet.dart index 4860635..6d817c6 100644 --- a/lib/model/finance/add_payment_request_bottom_sheet.dart +++ b/lib/model/finance/add_payment_request_bottom_sheet.dart @@ -1,3 +1,4 @@ +// payment_request_bottom_sheet.dart import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/finance/add_payment_request_controller.dart'; @@ -9,7 +10,6 @@ import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; -/// Show Payment Request Bottom Sheet Future showPaymentRequestBottomSheet({bool isEdit = false}) { return Get.bottomSheet( _PaymentRequestBottomSheet(isEdit: isEdit), @@ -19,7 +19,6 @@ Future showPaymentRequestBottomSheet({bool isEdit = false}) { class _PaymentRequestBottomSheet extends StatefulWidget { final bool isEdit; - const _PaymentRequestBottomSheet({this.isEdit = false}); @override @@ -29,223 +28,129 @@ class _PaymentRequestBottomSheet extends StatefulWidget { class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> with UIMixin { - final PaymentRequestController controller = - Get.put(PaymentRequestController()); + final controller = Get.put(PaymentRequestController()); final _formKey = GlobalKey(); - final GlobalKey _projectDropdownKey = GlobalKey(); - final GlobalKey _categoryDropdownKey = GlobalKey(); - final GlobalKey _payeeDropdownKey = GlobalKey(); - final GlobalKey _currencyDropdownKey = GlobalKey(); + final _projectDropdownKey = GlobalKey(); + final _categoryDropdownKey = GlobalKey(); + final _currencyDropdownKey = GlobalKey(); @override Widget build(BuildContext context) { - return Obx( - () => Form( - key: _formKey, - child: BaseBottomSheet( - title: - widget.isEdit ? "Edit Payment Request" : "Create Payment Request", - isSubmitting: false, - onCancel: Get.back, - onSubmit: () { - if (_formKey.currentState!.validate() && _validateSelections()) { - // Call your submit API here - showAppSnackbar( - title: "Success", - message: "Payment request submitted!", - type: SnackbarType.success, - ); - Get.back(); - } - }, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildDropdownField( - icon: Icons.work_outline, - title: " Select Project", - requiredField: true, - value: controller.selectedProject.value.isEmpty - ? "Select Project" - : controller.selectedProject.value, - onTap: () => _showOptionList( - controller.globalProjects.toList(), - (p) => p, - controller.selectProject, - _projectDropdownKey, - ), - dropdownKey: _projectDropdownKey, - ), - _gap(), - _buildDropdownField( - icon: Icons.category_outlined, - title: "Expense Category", - requiredField: true, - value: controller.selectedCategory.value?.name ?? - "Select Category", - onTap: () => _showOptionList( - controller.categories.toList(), + return Obx(() => Form( + key: _formKey, + child: BaseBottomSheet( + title: widget.isEdit + ? "Edit Payment Request" + : "Create Payment Request", + isSubmitting: controller.isSubmitting.value, + onCancel: Get.back, + onSubmit: () async { + if (_formKey.currentState!.validate() && _validateSelections()) { + final success = await controller.submitPaymentRequest(); + if (success) { + // First close the BottomSheet + Get.back(); + // Then show Snackbar + showAppSnackbar( + title: "Success", + message: "Payment request created successfully!", + type: SnackbarType.success, + ); + } + } + }, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDropdown( + "Select Project", + Icons.work_outline, + controller.selectedProject.value?['name'] ?? + "Select Project", + controller.globalProjects, + (p) => p['name'], + controller.selectProject, + key: _projectDropdownKey), + _gap(), + _buildDropdown( + "Expense Category", + Icons.category_outlined, + controller.selectedCategory.value?.name ?? + "Select Category", + controller.categories, (c) => c.name, controller.selectCategory, - _categoryDropdownKey), - dropdownKey: _categoryDropdownKey, - ), - _gap(), - _buildTextField( - icon: Icons.title_outlined, - title: "Title", - controller: TextEditingController(), - hint: "Enter title", - validator: Validators.requiredField, - ), - _gap(), - // Is Advance Payment Radio Buttons with Icon and Primary Color - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.attach_money_outlined, size: 20), - SizedBox(width: 6), - Text( - "Is Advance Payment", - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - MySpacing.height(6), - Obx(() => Row( - children: [ - Expanded( - child: RadioListTile( - contentPadding: EdgeInsets.zero, - title: Text("Yes"), - value: true, - groupValue: controller.isAdvancePayment.value, - activeColor: contentTheme.primary, - onChanged: (val) { - if (val != null) - controller.isAdvancePayment.value = val; - }, - ), - ), - Expanded( - child: RadioListTile( - contentPadding: EdgeInsets.zero, - title: Text("No"), - value: false, - groupValue: controller.isAdvancePayment.value, - activeColor: contentTheme.primary, - onChanged: (val) { - if (val != null) - controller.isAdvancePayment.value = val; - }, - ), - ), - ], - )), - _gap(), - ], - ), - - _buildTextField( - icon: Icons.calendar_today, - title: "Due To Date", - controller: TextEditingController(), - hint: "DD-MM-YYYY", - validator: Validators.requiredField, - ), - _gap(), - _buildTextField( - icon: Icons.currency_rupee, - title: "Amount", - controller: TextEditingController(), - hint: "Enter Amount", - keyboardType: TextInputType.number, - validator: (v) => - (v != null && v.isNotEmpty && double.tryParse(v) != null) + key: _categoryDropdownKey), + _gap(), + _buildTextField( + "Title", Icons.title_outlined, controller.titleController, + hint: "Enter title", validator: Validators.requiredField), + _gap(), + _buildRadio("Is Advance Payment", Icons.attach_money_outlined, + controller.isAdvancePayment, ["Yes", "No"]), + _gap(), + _buildDueDateField(), + _gap(), + _buildTextField("Amount", Icons.currency_rupee, + controller.amountController, + hint: "Enter Amount", + keyboardType: TextInputType.number, + validator: (v) => (v != null && + v.isNotEmpty && + double.tryParse(v) != null) ? null - : "Enter valid amount", - ), - _gap(), - _buildDropdownField( - icon: Icons.person_outline, - title: "Payee", - requiredField: true, - value: controller.selectedPayee.value.isEmpty - ? "Select Payee" - : controller.selectedPayee.value, - onTap: () => _showOptionList(controller.payees.toList(), - (p) => p, controller.selectPayee, _payeeDropdownKey), - dropdownKey: _payeeDropdownKey, - ), - _gap(), - _buildDropdownField( - icon: Icons.monetization_on_outlined, - title: "Currency", - requiredField: true, - value: controller.selectedCurrency.value?.currencyName ?? - "Select Currency", - onTap: () => _showOptionList( - controller.currencies.toList(), - (c) => c.currencyName, // <-- changed here + : "Enter valid amount"), + _gap(), + _buildPayeeAutocompleteField(), + _gap(), + _buildDropdown( + "Currency", + Icons.monetization_on_outlined, + controller.selectedCurrency.value?.currencyName ?? + "Select Currency", + controller.currencies, + (c) => c.currencyName, controller.selectCurrency, - _currencyDropdownKey), - dropdownKey: _currencyDropdownKey, - ), - _gap(), - _buildTextField( - icon: Icons.description_outlined, - title: "Description", - controller: TextEditingController(), - hint: "Enter description", - maxLines: 3, - validator: Validators.requiredField, - ), - _gap(), - _buildAttachmentsSection(), - ], + key: _currencyDropdownKey), + _gap(), + _buildTextField("Description", Icons.description_outlined, + controller.descriptionController, + hint: "Enter description", + maxLines: 3, + validator: Validators.requiredField), + _gap(), + _buildAttachmentsSection(), + ], + ), ), ), - ), - ), - ); + )); } - 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, - }) { + Widget _buildDropdown(String title, IconData icon, String value, + List options, String Function(T) getLabel, ValueChanged onSelected, + {required GlobalKey key}) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SectionTitle(icon: icon, title: title, requiredField: requiredField), + SectionTitle(icon: icon, title: title, requiredField: true), MySpacing.height(6), - DropdownTile(key: dropdownKey, title: value, onTap: onTap), + DropdownTile( + key: key, + title: value, + onTap: () => _showOptionList(options, getLabel, onSelected, key)), ], ); } - Widget _buildTextField({ - required IconData icon, - required String title, - required TextEditingController controller, - String? hint, - TextInputType? keyboardType, - FormFieldValidator? validator, - int maxLines = 1, - }) { + Widget _buildTextField( + String title, IconData icon, TextEditingController controller, + {String? hint, + TextInputType? keyboardType, + FormFieldValidator? validator, + int maxLines = 1}) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -263,32 +168,168 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> ); } + Widget _buildRadio( + String title, IconData icon, RxBool controller, List labels) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 20), + const SizedBox(width: 6), + Text(title, + style: + const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + ], + ), + MySpacing.height(6), + Obx(() => Row( + children: labels.asMap().entries.map((entry) { + final i = entry.key; + final label = entry.value; + final value = i == 0; + return Expanded( + child: RadioListTile( + contentPadding: EdgeInsets.zero, + title: Text(label), + value: value, + groupValue: controller.value, + activeColor: contentTheme.primary, + onChanged: (val) => + val != null ? controller.value = val : null, + ), + ); + }).toList(), + )), + ], + ); + } + + Widget _buildDueDateField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionTitle( + icon: Icons.calendar_today, + title: "Due To Date", + requiredField: true), + MySpacing.height(6), + GestureDetector( + onTap: () => controller.pickDueDate(context), + child: AbsorbPointer( + child: TextFormField( + controller: controller.dueDateController, + decoration: InputDecoration( + hintText: "Select Due Date", + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + validator: (_) => controller.selectedDueDate.value == null + ? "Please select a due date" + : null, + ), + ), + ), + ], + ); + } + + Widget _buildPayeeAutocompleteField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle( + icon: Icons.person_outline, title: "Payee", requiredField: true), + const SizedBox(height: 6), + Autocomplete( + optionsBuilder: (textEditingValue) { + final query = textEditingValue.text.toLowerCase(); + return query.isEmpty + ? const Iterable.empty() + : controller.payees + .where((p) => p.toLowerCase().contains(query)); + }, + displayStringForOption: (option) => option, + fieldViewBuilder: + (context, fieldController, focusNode, onFieldSubmitted) { + // Avoid updating during build + WidgetsBinding.instance.addPostFrameCallback((_) { + if (fieldController.text != controller.selectedPayee.value) { + fieldController.text = controller.selectedPayee.value; + fieldController.selection = TextSelection.fromPosition( + TextPosition(offset: fieldController.text.length)); + } + }); + + return TextFormField( + controller: fieldController, + focusNode: focusNode, + decoration: InputDecoration( + hintText: "Type or select payee", + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + validator: (v) => + v == null || v.trim().isEmpty ? "Please enter payee" : null, + onChanged: (val) => controller.selectedPayee.value = val, + ); + }, + onSelected: (selection) => controller.selectedPayee.value = selection, + optionsViewBuilder: (context, onSelected, options) => Material( + color: Colors.white, + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200, minWidth: 300), + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: options.length, + itemBuilder: (_, index) => InkWell( + onTap: () => onSelected(options.elementAt(index)), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 10, horizontal: 12), + child: Text(options.elementAt(index), + style: const TextStyle(fontSize: 14)), + ), + ), + ), + ), + ), + ), + ], + ); + } + Widget _buildAttachmentsSection() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SectionTitle( - icon: Icons.attach_file, - title: "Attachments", - requiredField: true, - ), + icon: Icons.attach_file, title: "Attachments", requiredField: true), MySpacing.height(10), Obx(() { if (controller.isProcessingAttachment.value) { return Center( child: Column( children: [ - CircularProgressIndicator( - color: contentTheme.primary, - ), + CircularProgressIndicator(color: contentTheme.primary), const SizedBox(height: 8), - Text( - "Processing image, please wait...", - style: TextStyle( - fontSize: 14, - color: contentTheme.primary, - ), - ), + Text("Processing image, please wait...", + style: + TextStyle(fontSize: 14, color: contentTheme.primary)), ], ), ); @@ -316,10 +357,9 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> controller.existingAttachments.refresh(); } showAppSnackbar( - title: 'Removed', - message: 'Attachment has been removed.', - type: SnackbarType.success, - ); + title: 'Removed', + message: 'Attachment has been removed.', + type: SnackbarType.success); Navigator.pop(context); }, ), @@ -332,7 +372,8 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> ); } - /// Generic option list for dropdowns + Widget _gap([double h = 16]) => MySpacing.height(h); + Future _showOptionList(List options, String Function(T) getLabel, ValueChanged onSelected, GlobalKey key) async { if (options.isEmpty) { @@ -340,6 +381,22 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> return; } + if (key.currentContext == null) { + final selected = await showDialog( + context: context, + builder: (_) => SimpleDialog( + children: options + .map((opt) => SimpleDialogOption( + onPressed: () => Navigator.pop(context, opt), + child: Text(getLabel(opt)), + )) + .toList(), + ), + ); + if (selected != null) onSelected(selected); + return; + } + final RenderBox button = key.currentContext!.findRenderObject() as RenderBox; final RenderBox overlay = @@ -349,17 +406,14 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> 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, - ), + 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)), - )) + .map( + (opt) => PopupMenuItem(value: opt, child: Text(getLabel(opt)))) .toList(), ); @@ -367,30 +421,21 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> } bool _validateSelections() { - if (controller.selectedProject.value.isEmpty) { - _showError("Please select a project"); - return false; - } - if (controller.selectedCategory.value == null) { - _showError("Please select a category"); - return false; - } - if (controller.selectedPayee.value.isEmpty) { - _showError("Please select a payee"); - return false; - } - if (controller.selectedCurrency.value == null) { - _showError("Please select currency"); - return false; + if (controller.selectedProject.value == null || + controller.selectedProject.value!['id'].toString().isEmpty) { + return _showError("Please select a project"); } + if (controller.selectedCategory.value == null) + return _showError("Please select a category"); + if (controller.selectedPayee.value.isEmpty) + return _showError("Please select a payee"); + if (controller.selectedCurrency.value == null) + return _showError("Please select currency"); return true; } - void _showError(String msg) { - showAppSnackbar( - title: "Error", - message: msg, - type: SnackbarType.error, - ); + bool _showError(String msg) { + showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error); + return false; } } diff --git a/lib/view/finance/finance_screen.dart b/lib/view/finance/finance_screen.dart index b513780..a67968f 100644 --- a/lib/view/finance/finance_screen.dart +++ b/lib/view/finance/finance_screen.dart @@ -116,7 +116,7 @@ class _FinanceScreenState extends State showPaymentRequestBottomSheet(); }, backgroundColor: contentTheme.primary, - child: const Icon(Icons.add), + child: Icon(Icons.add), tooltip: "Create Payment Request", ), ); diff --git a/pubspec.yaml b/pubspec.yaml index 9d426c4..fcaef42 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -81,6 +81,7 @@ dependencies: device_info_plus: ^11.3.0 flutter_local_notifications: 19.4.0 equatable: ^2.0.7 + mime: ^2.0.0 timeline_tile: ^2.0.0 dev_dependencies: