From ac01ee8e4764e54544c76fcdefa39a78589b0f8b Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 5 Nov 2025 17:30:07 +0530 Subject: [PATCH] added payment request code --- .../add_payment_request_controller.dart | 260 ++++++++++++ lib/helpers/services/api_endpoints.dart | 11 +- lib/helpers/services/api_service.dart | 90 ++++ .../widgets/expense/expense_form_widgets.dart | 26 +- .../expense/add_expense_bottom_sheet.dart | 1 + .../add_payment_request_bottom_sheet.dart | 396 ++++++++++++++++++ lib/model/finance/currency_list_model.dart | 77 ++++ lib/model/finance/expense_category_model.dart | 77 ++++ .../finance/payment_payee_request_model.dart | 39 ++ lib/view/finance/finance_screen.dart | 1 + 10 files changed, 969 insertions(+), 9 deletions(-) create mode 100644 lib/controller/finance/add_payment_request_controller.dart create mode 100644 lib/model/finance/add_payment_request_bottom_sheet.dart create mode 100644 lib/model/finance/currency_list_model.dart create mode 100644 lib/model/finance/expense_category_model.dart create mode 100644 lib/model/finance/payment_payee_request_model.dart diff --git a/lib/controller/finance/add_payment_request_controller.dart b/lib/controller/finance/add_payment_request_controller.dart new file mode 100644 index 0000000..432a245 --- /dev/null +++ b/lib/controller/finance/add_payment_request_controller.dart @@ -0,0 +1,260 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:get/get.dart'; +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:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/widgets/time_stamp_image_helper.dart'; +import 'package:marco/model/finance/expense_category_model.dart'; +import 'package:marco/model/finance/currency_list_model.dart'; + +class PaymentRequestController extends GetxController { + // ───────────────────────────────────────────── + // 🔹 Loading States + // ───────────────────────────────────────────── + final isLoadingPayees = false.obs; + final isLoadingCategories = false.obs; + final isLoadingCurrencies = false.obs; + final isProcessingAttachment = false.obs; + + // ───────────────────────────────────────────── + // 🔹 Data Lists + // ───────────────────────────────────────────── + final payees = [].obs; + final categories = [].obs; + final currencies = [].obs; + final globalProjects = [].obs; + + // ───────────────────────────────────────────── + // 🔹 Selected Values + // ───────────────────────────────────────────── + final selectedCategory = Rx(null); + final selectedPayee = ''.obs; + final selectedCurrency = Rx(null); + final selectedProject = ''.obs; + final isAdvancePayment = false.obs; + + // ───────────────────────────────────────────── + // 🔹 Text Controllers + // ───────────────────────────────────────────── + final titleController = TextEditingController(); + final dueDateController = TextEditingController(); + final amountController = TextEditingController(); + final descriptionController = TextEditingController(); + + // ───────────────────────────────────────────── + // 🔹 Attachments + // ───────────────────────────────────────────── + final attachments = [].obs; + final existingAttachments = >[].obs; + final ImagePicker _picker = ImagePicker(); + + // ───────────────────────────────────────────── + // 🔹 Lifecycle + // ───────────────────────────────────────────── + @override + void onInit() { + super.onInit(); + fetchAllMasterData(); + fetchGlobalProjects(); + } + + @override + void onClose() { + titleController.dispose(); + dueDateController.dispose(); + amountController.dispose(); + descriptionController.dispose(); + super.onClose(); + } + + // ───────────────────────────────────────────── + // 🔹 Master Data Fetch + // ───────────────────────────────────────────── + Future fetchAllMasterData() async { + await Future.wait([ + fetchPayees(), + fetchExpenseCategories(), + fetchCurrencies(), + ]); + } + + Future fetchPayees() async { + try { + isLoadingPayees.value = true; + final response = await ApiService.getExpensePaymentRequestPayeeApi(); + if (response != null && response.data.isNotEmpty) { + payees.value = response.data; + } else { + payees.clear(); + } + } catch (e) { + logSafe("Error fetching payees: $e", level: LogLevel.error); + } 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; + } + } + + 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(); + } + } catch (e) { + logSafe("Error fetching projects: $e", level: LogLevel.error); + globalProjects.clear(); + } + } + + // ───────────────────────────────────────────── + // 🔹 File / Image Pickers + // ───────────────────────────────────────────── + + /// 📂 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"); + } + } + + /// 📸 Pick from camera and auto add timestamp + Future pickFromCamera() 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(); + } + } catch (e) { + _errorSnackbar("Camera 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; + 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) + // ───────────────────────────────────────────── + Future>> buildAttachmentPayload() async { + final existingPayload = existingAttachments + .map((e) => { + "documentId": e['documentId'], + "fileName": e['fileName'], + "contentType": e['contentType'] ?? 'application/octet-stream', + "fileSize": e['fileSize'] ?? 0, + "description": "", + "url": e['url'], + "isActive": e['isActive'] ?? true, + "base64Data": "", + }) + .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": "", + }; + }), + ); + + return [...existingPayload, ...newPayload]; + } + + // ───────────────────────────────────────────── + // 🔹 Snackbar Helper + // ───────────────────────────────────────────── + void _errorSnackbar(String msg, [String title = "Error"]) { + showAppSnackbar(title: title, message: msg, type: SnackbarType.error); + } +} diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index b9c4d9c..cdf7cb8 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,10 +1,17 @@ class ApiEndpoints { // static const String baseUrl = "https://mapi.marcoaiot.com/api"; // static const String baseUrl = "https://ofwapi.marcoaiot.com/api"; - static const String baseUrl = "https://api.onfieldwork.com/api"; + // static const String baseUrl = "https://api.onfieldwork.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api"; - // static const String baseUrl = "https://devapi.marcoaiot.com/api"; + static const String baseUrl = "https://devapi.marcoaiot.com/api"; + // Finance Module API Endpoints + + static const String getMasterCurrencies = "/Master/currencies/list"; + static const String getMasterExpensesCategories = + "/Master/expenses-categories"; + static const String getExpensePaymentRequestPayee = + "/Expense/payment-request/payee"; // Dashboard Module API Endpoints static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 13aecfa..d0fc2b6 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -22,6 +22,10 @@ import 'package:marco/model/attendance/organization_per_project_list_model.dart' import 'package:marco/model/dashboard/pending_expenses_model.dart'; import 'package:marco/model/dashboard/expense_type_report_model.dart'; import 'package:marco/model/dashboard/monthly_expence_model.dart'; +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; @@ -291,6 +295,92 @@ class ApiService { } } + /// Get Master Currencies + static Future getMasterCurrenciesApi() async { + const endpoint = ApiEndpoints.getMasterCurrencies; + logSafe("Fetching Master Currencies"); + + try { + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("Master Currencies request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = + _parseResponseForAllData(response, label: "Master Currencies"); + if (jsonResponse != null) { + return CurrencyListResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getMasterCurrenciesApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + + /// Get Master Expense Categories + static Future + getMasterExpenseCategoriesApi() async { + const endpoint = ApiEndpoints.getMasterExpensesCategories; + logSafe("Fetching Master Expense Categories"); + + try { + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("Master Expense Categories request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData(response, + label: "Master Expense Categories"); + if (jsonResponse != null) { + return ExpenseCategoryResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getMasterExpenseCategoriesApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + + /// Get Expense Payment Request Payee + static Future + getExpensePaymentRequestPayeeApi() async { + const endpoint = ApiEndpoints.getExpensePaymentRequestPayee; + logSafe("Fetching Expense Payment Request Payees"); + + try { + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("Expense Payment Request Payee request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData(response, + label: "Expense Payment Request Payee"); + if (jsonResponse != null) { + return PaymentRequestPayeeResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getExpensePaymentRequestPayeeApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + /// Get Monthly Expense Report (categoryId is optional) static Future getDashboardMonthlyExpensesApi({ diff --git a/lib/helpers/widgets/expense/expense_form_widgets.dart b/lib/helpers/widgets/expense/expense_form_widgets.dart index e921683..137fa0c 100644 --- a/lib/helpers/widgets/expense/expense_form_widgets.dart +++ b/lib/helpers/widgets/expense/expense_form_widgets.dart @@ -1,4 +1,4 @@ -// expense_form_widgets.dart +// form_widgets.dart import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -6,7 +6,6 @@ import 'package:url_launcher/url_launcher_string.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; -import 'package:marco/controller/expense/add_expense_controller.dart'; /// 🔹 Common Colors & Styles final _hintStyle = TextStyle(fontSize: 14, color: Colors.grey[600]); @@ -161,9 +160,10 @@ class TileContainer extends StatelessWidget { } /// ========================== -/// Attachments Section +/// Attachments Section (Reusable) /// ========================== -class AttachmentsSection extends StatelessWidget { +class AttachmentsSection extends StatelessWidget { + final T controller; // 🔹 Now any controller can be passed final RxList attachments; final RxList> existingAttachments; final ValueChanged onRemoveNew; @@ -171,6 +171,7 @@ class AttachmentsSection extends StatelessWidget { final VoidCallback onAdd; const AttachmentsSection({ + required this.controller, required this.attachments, required this.existingAttachments, required this.onRemoveNew, @@ -239,8 +240,20 @@ class AttachmentsSection extends StatelessWidget { ), )), _buildActionTile(Icons.attach_file, onAdd), - _buildActionTile(Icons.camera_alt, - () => Get.find().pickFromCamera()), + _buildActionTile(Icons.camera_alt, () { + // 🔹 Dynamically call pickFromCamera if it exists + final fn = controller as dynamic; + if (fn.pickFromCamera != null) { + fn.pickFromCamera(); + } else { + showAppSnackbar( + title: 'Error', + message: + 'This controller does not support camera attachments.', + type: SnackbarType.error, + ); + } + }), ], ), ], @@ -402,7 +415,6 @@ class _AttachmentTile extends StatelessWidget { ); } - /// map extensions to icons/colors static (IconData, Color) _fileIcon(String ext) { switch (ext) { case 'pdf': diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 7d0ef4b..68e3f9d 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -457,6 +457,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> attachments: controller.attachments, existingAttachments: controller.existingAttachments, onRemoveNew: controller.removeAttachment, + controller: controller, onRemoveExisting: (item) async { await showDialog( context: context, diff --git a/lib/model/finance/add_payment_request_bottom_sheet.dart b/lib/model/finance/add_payment_request_bottom_sheet.dart new file mode 100644 index 0000000..4860635 --- /dev/null +++ b/lib/model/finance/add_payment_request_bottom_sheet.dart @@ -0,0 +1,396 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/finance/add_payment_request_controller.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/helpers/utils/validators.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +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), + isScrollControlled: true, + ); +} + +class _PaymentRequestBottomSheet extends StatefulWidget { + final bool isEdit; + + const _PaymentRequestBottomSheet({this.isEdit = false}); + + @override + State<_PaymentRequestBottomSheet> createState() => + _PaymentRequestBottomSheetState(); +} + +class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> + with UIMixin { + final PaymentRequestController controller = + Get.put(PaymentRequestController()); + final _formKey = GlobalKey(); + + final GlobalKey _projectDropdownKey = GlobalKey(); + final GlobalKey _categoryDropdownKey = GlobalKey(); + final GlobalKey _payeeDropdownKey = GlobalKey(); + final GlobalKey _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(), + (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) + ? 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 + 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(), + ], + ), + ), + ), + ), + ); + } + + 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, + 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 _buildAttachmentsSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionTitle( + 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, + ), + const SizedBox(height: 8), + Text( + "Processing image, please wait...", + style: TextStyle( + fontSize: 14, + color: contentTheme.primary, + ), + ), + ], + ), + ); + } + + return AttachmentsSection( + attachments: controller.attachments, + existingAttachments: controller.existingAttachments, + onRemoveNew: controller.removeAttachment, + controller: controller, + 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, + ); + Navigator.pop(context); + }, + ), + ); + }, + onAdd: controller.pickAttachments, + ); + }), + ], + ); + } + + /// Generic option list for dropdowns + Future _showOptionList(List options, String Function(T) getLabel, + ValueChanged onSelected, GlobalKey key) async { + if (options.isEmpty) { + _showError("No options available"); + return; + } + + 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); + + 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); + } + + 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; + } + return true; + } + + void _showError(String msg) { + showAppSnackbar( + title: "Error", + message: msg, + type: SnackbarType.error, + ); + } +} diff --git a/lib/model/finance/currency_list_model.dart b/lib/model/finance/currency_list_model.dart new file mode 100644 index 0000000..b6410e0 --- /dev/null +++ b/lib/model/finance/currency_list_model.dart @@ -0,0 +1,77 @@ +class CurrencyListResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + CurrencyListResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory CurrencyListResponse.fromJson(Map json) { + return CurrencyListResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: (json['data'] as List? ?? []) + .map((e) => Currency.fromJson(e)) + .toList(), + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data.map((e) => e.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; + } +} + +class Currency { + final String id; + final String currencyCode; + final String currencyName; + final String symbol; + final bool isActive; + + Currency({ + required this.id, + required this.currencyCode, + required this.currencyName, + required this.symbol, + required this.isActive, + }); + + factory Currency.fromJson(Map json) { + return Currency( + id: json['id'] ?? '', + currencyCode: json['currencyCode'] ?? '', + currencyName: json['currencyName'] ?? '', + symbol: json['symbol'] ?? '', + isActive: json['isActive'] ?? false, + ); + } + + Map toJson() { + return { + 'id': id, + 'currencyCode': currencyCode, + 'currencyName': currencyName, + 'symbol': symbol, + 'isActive': isActive, + }; + } +} diff --git a/lib/model/finance/expense_category_model.dart b/lib/model/finance/expense_category_model.dart new file mode 100644 index 0000000..f1cf5fb --- /dev/null +++ b/lib/model/finance/expense_category_model.dart @@ -0,0 +1,77 @@ +class ExpenseCategoryResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + ExpenseCategoryResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory ExpenseCategoryResponse.fromJson(Map json) { + return ExpenseCategoryResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: (json['data'] as List? ?? []) + .map((e) => ExpenseCategory.fromJson(e)) + .toList(), + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data.map((e) => e.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; + } +} + +class ExpenseCategory { + final String id; + final String name; + final bool noOfPersonsRequired; + final bool isAttachmentRequried; + final String description; + + ExpenseCategory({ + required this.id, + required this.name, + required this.noOfPersonsRequired, + required this.isAttachmentRequried, + required this.description, + }); + + factory ExpenseCategory.fromJson(Map json) { + return ExpenseCategory( + id: json['id'] ?? '', + name: json['name'] ?? '', + noOfPersonsRequired: json['noOfPersonsRequired'] ?? false, + isAttachmentRequried: json['isAttachmentRequried'] ?? false, + description: json['description'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'noOfPersonsRequired': noOfPersonsRequired, + 'isAttachmentRequried': isAttachmentRequried, + 'description': description, + }; + } +} diff --git a/lib/model/finance/payment_payee_request_model.dart b/lib/model/finance/payment_payee_request_model.dart new file mode 100644 index 0000000..5d0dcc9 --- /dev/null +++ b/lib/model/finance/payment_payee_request_model.dart @@ -0,0 +1,39 @@ +class PaymentRequestPayeeResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + PaymentRequestPayeeResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory PaymentRequestPayeeResponse.fromJson(Map json) { + return PaymentRequestPayeeResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: List.from(json['data'] ?? []), + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data, + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; + } +} diff --git a/lib/view/finance/finance_screen.dart b/lib/view/finance/finance_screen.dart index 49d3e8a..bc2b828 100644 --- a/lib/view/finance/finance_screen.dart +++ b/lib/view/finance/finance_screen.dart @@ -5,6 +5,7 @@ import 'package:marco/controller/project_controller.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/finance/add_payment_request_bottom_sheet.dart'; class FinanceScreen extends StatefulWidget { const FinanceScreen({super.key});