made chnages in request payment
This commit is contained in:
parent
42c2739d0c
commit
1a6ad4edfc
@ -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 = <String>[].obs;
|
||||
final categories = <ExpenseCategory>[].obs;
|
||||
final currencies = <Currency>[].obs;
|
||||
final globalProjects = <String>[].obs;
|
||||
final globalProjects = <Map<String, dynamic>>[].obs;
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 🔹 Selected Values
|
||||
// ─────────────────────────────────────────────
|
||||
// Selected Values
|
||||
final selectedProject = Rx<Map<String, dynamic>?>(null);
|
||||
final selectedCategory = Rx<ExpenseCategory?>(null);
|
||||
final selectedPayee = ''.obs;
|
||||
final selectedCurrency = Rx<Currency?>(null);
|
||||
final selectedProject = ''.obs;
|
||||
final isAdvancePayment = false.obs;
|
||||
final selectedDueDate = Rx<DateTime?>(null);
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 🔹 Text Controllers
|
||||
// ─────────────────────────────────────────────
|
||||
// Text Controllers
|
||||
final titleController = TextEditingController();
|
||||
final dueDateController = TextEditingController();
|
||||
final amountController = TextEditingController();
|
||||
final descriptionController = TextEditingController();
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 🔹 Attachments
|
||||
// ─────────────────────────────────────────────
|
||||
// Attachments
|
||||
final attachments = <File>[].obs;
|
||||
final existingAttachments = <Map<String, dynamic>>[].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<void> fetchAllMasterData() async {
|
||||
await Future.wait([
|
||||
fetchPayees(),
|
||||
fetchExpenseCategories(),
|
||||
fetchCurrencies(),
|
||||
_fetchData(
|
||||
payees, ApiService.getExpensePaymentRequestPayeeApi, isLoadingPayees),
|
||||
_fetchData(categories, ApiService.getMasterExpenseCategoriesApi,
|
||||
isLoadingCategories),
|
||||
_fetchData(
|
||||
currencies, ApiService.getMasterCurrenciesApi, isLoadingCurrencies),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> fetchPayees() async {
|
||||
/// Generic fetch handler
|
||||
Future<void> _fetchData<T>(
|
||||
RxList<T> list, Future<dynamic> 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<void> 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<void> 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<void> fetchGlobalProjects() async {
|
||||
try {
|
||||
final response = await ApiService.getGlobalProjects();
|
||||
if (response != null && response.isNotEmpty) {
|
||||
globalProjects.value = response
|
||||
.map<String>((e) => e['name']?.toString().trim() ?? '')
|
||||
.where((name) => name.isNotEmpty)
|
||||
.toList();
|
||||
} else {
|
||||
globalProjects.clear();
|
||||
}
|
||||
globalProjects.value = (response ?? [])
|
||||
.map<Map<String, dynamic>>((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<void> 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<void> 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<String>().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<void> pickFromCamera() async {
|
||||
/// Generic file picker for multiple sources
|
||||
Future<void> 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<String>().map(File.new));
|
||||
}
|
||||
attachments.refresh();
|
||||
} catch (e) {
|
||||
_errorSnackbar("Camera error: $e");
|
||||
_errorSnackbar("Attachment error: $e");
|
||||
} finally {
|
||||
isProcessingAttachment.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 🖼️ Pick from gallery
|
||||
Future<void> 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<String, dynamic> 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<String, dynamic> file) =>
|
||||
existingAttachments.remove(file);
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 🔹 Payload Builder (for upload)
|
||||
// ─────────────────────────────────────────────
|
||||
/// Build attachment payload
|
||||
Future<List<Map<String, dynamic>>> 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<bool> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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<bool> 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<Map<String, dynamic>> 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<CurrencyListResponse?> getMasterCurrenciesApi() async {
|
||||
const endpoint = ApiEndpoints.getMasterCurrencies;
|
||||
|
||||
@ -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<T?> showPaymentRequestBottomSheet<T>({bool isEdit = false}) {
|
||||
return Get.bottomSheet<T>(
|
||||
_PaymentRequestBottomSheet(isEdit: isEdit),
|
||||
@ -19,7 +19,6 @@ Future<T?> showPaymentRequestBottomSheet<T>({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<FormState>();
|
||||
|
||||
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<String>(
|
||||
icon: Icons.work_outline,
|
||||
title: " Select Project",
|
||||
requiredField: true,
|
||||
value: controller.selectedProject.value.isEmpty
|
||||
? "Select Project"
|
||||
: controller.selectedProject.value,
|
||||
onTap: () => _showOptionList<String>(
|
||||
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<bool>(
|
||||
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<bool>(
|
||||
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<String>(
|
||||
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<T>({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required bool requiredField,
|
||||
required String value,
|
||||
required VoidCallback onTap,
|
||||
required GlobalKey dropdownKey,
|
||||
}) {
|
||||
Widget _buildDropdown<T>(String title, IconData icon, String value,
|
||||
List<T> options, String Function(T) getLabel, ValueChanged<T> 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<String>? validator,
|
||||
int maxLines = 1,
|
||||
}) {
|
||||
Widget _buildTextField(
|
||||
String title, IconData icon, TextEditingController controller,
|
||||
{String? hint,
|
||||
TextInputType? keyboardType,
|
||||
FormFieldValidator<String>? 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<String> 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<bool>(
|
||||
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<String>(
|
||||
optionsBuilder: (textEditingValue) {
|
||||
final query = textEditingValue.text.toLowerCase();
|
||||
return query.isEmpty
|
||||
? const Iterable<String>.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<void> _showOptionList<T>(List<T> options, String Function(T) getLabel,
|
||||
ValueChanged<T> 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<T>(
|
||||
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<T>(
|
||||
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<T>(
|
||||
value: opt,
|
||||
child: Text(getLabel(opt)),
|
||||
))
|
||||
.map(
|
||||
(opt) => PopupMenuItem<T>(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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,7 +116,7 @@ class _FinanceScreenState extends State<FinanceScreen>
|
||||
showPaymentRequestBottomSheet();
|
||||
},
|
||||
backgroundColor: contentTheme.primary,
|
||||
child: const Icon(Icons.add),
|
||||
child: Icon(Icons.add),
|
||||
tooltip: "Create Payment Request",
|
||||
),
|
||||
);
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user