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:io';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
@ -5,6 +6,7 @@ import 'package:file_picker/file_picker.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:mime/mime.dart';
|
import 'package:mime/mime.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import 'package:marco/helpers/services/api_service.dart';
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
import 'package:marco/helpers/services/app_logger.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';
|
import 'package:marco/model/finance/currency_list_model.dart';
|
||||||
|
|
||||||
class PaymentRequestController extends GetxController {
|
class PaymentRequestController extends GetxController {
|
||||||
// ─────────────────────────────────────────────
|
// Loading States
|
||||||
// 🔹 Loading States
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
final isLoadingPayees = false.obs;
|
final isLoadingPayees = false.obs;
|
||||||
final isLoadingCategories = false.obs;
|
final isLoadingCategories = false.obs;
|
||||||
final isLoadingCurrencies = false.obs;
|
final isLoadingCurrencies = false.obs;
|
||||||
final isProcessingAttachment = false.obs;
|
final isProcessingAttachment = false.obs;
|
||||||
|
final isSubmitting = false.obs;
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
// Data Lists
|
||||||
// 🔹 Data Lists
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
final payees = <String>[].obs;
|
final payees = <String>[].obs;
|
||||||
final categories = <ExpenseCategory>[].obs;
|
final categories = <ExpenseCategory>[].obs;
|
||||||
final currencies = <Currency>[].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 selectedCategory = Rx<ExpenseCategory?>(null);
|
||||||
final selectedPayee = ''.obs;
|
final selectedPayee = ''.obs;
|
||||||
final selectedCurrency = Rx<Currency?>(null);
|
final selectedCurrency = Rx<Currency?>(null);
|
||||||
final selectedProject = ''.obs;
|
|
||||||
final isAdvancePayment = false.obs;
|
final isAdvancePayment = false.obs;
|
||||||
|
final selectedDueDate = Rx<DateTime?>(null);
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
// Text Controllers
|
||||||
// 🔹 Text Controllers
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
final titleController = TextEditingController();
|
final titleController = TextEditingController();
|
||||||
final dueDateController = TextEditingController();
|
final dueDateController = TextEditingController();
|
||||||
final amountController = TextEditingController();
|
final amountController = TextEditingController();
|
||||||
final descriptionController = TextEditingController();
|
final descriptionController = TextEditingController();
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
// Attachments
|
||||||
// 🔹 Attachments
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
final attachments = <File>[].obs;
|
final attachments = <File>[].obs;
|
||||||
final existingAttachments = <Map<String, dynamic>>[].obs;
|
final existingAttachments = <Map<String, dynamic>>[].obs;
|
||||||
final ImagePicker _picker = ImagePicker();
|
final ImagePicker _picker = ImagePicker();
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// 🔹 Lifecycle
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
@ -73,153 +64,110 @@ class PaymentRequestController extends GetxController {
|
|||||||
super.onClose();
|
super.onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
/// Fetch all master data concurrently
|
||||||
// 🔹 Master Data Fetch
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
Future<void> fetchAllMasterData() async {
|
Future<void> fetchAllMasterData() async {
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
fetchPayees(),
|
_fetchData(
|
||||||
fetchExpenseCategories(),
|
payees, ApiService.getExpensePaymentRequestPayeeApi, isLoadingPayees),
|
||||||
fetchCurrencies(),
|
_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 {
|
try {
|
||||||
isLoadingPayees.value = true;
|
loader.value = true;
|
||||||
final response = await ApiService.getExpensePaymentRequestPayeeApi();
|
final response = await apiCall();
|
||||||
if (response != null && response.data.isNotEmpty) {
|
if (response != null && response.data.isNotEmpty) {
|
||||||
payees.value = response.data;
|
list.value = response.data;
|
||||||
} else {
|
} else {
|
||||||
payees.clear();
|
list.clear();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logSafe("Error fetching payees: $e", level: LogLevel.error);
|
logSafe("Error fetching data: $e", level: LogLevel.error);
|
||||||
|
list.clear();
|
||||||
} finally {
|
} finally {
|
||||||
isLoadingPayees.value = false;
|
loader.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch projects
|
||||||
Future<void> fetchGlobalProjects() async {
|
Future<void> fetchGlobalProjects() async {
|
||||||
try {
|
try {
|
||||||
final response = await ApiService.getGlobalProjects();
|
final response = await ApiService.getGlobalProjects();
|
||||||
if (response != null && response.isNotEmpty) {
|
globalProjects.value = (response ?? [])
|
||||||
globalProjects.value = response
|
.map<Map<String, dynamic>>((e) => {
|
||||||
.map<String>((e) => e['name']?.toString().trim() ?? '')
|
'id': e['id']?.toString() ?? '',
|
||||||
.where((name) => name.isNotEmpty)
|
'name': e['name']?.toString().trim() ?? '',
|
||||||
.toList();
|
})
|
||||||
} else {
|
.where((p) => p['id']!.isNotEmpty && p['name']!.isNotEmpty)
|
||||||
globalProjects.clear();
|
.toList();
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logSafe("Error fetching projects: $e", level: LogLevel.error);
|
logSafe("Error fetching projects: $e", level: LogLevel.error);
|
||||||
globalProjects.clear();
|
globalProjects.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
/// Pick due date
|
||||||
// 🔹 File / Image Pickers
|
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)
|
if (pickedDate != null) {
|
||||||
Future<void> pickAttachments() async {
|
selectedDueDate.value = pickedDate;
|
||||||
try {
|
dueDateController.text = DateFormat('dd MMM yyyy').format(pickedDate);
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 📸 Pick from camera and auto add timestamp
|
/// Generic file picker for multiple sources
|
||||||
Future<void> pickFromCamera() async {
|
Future<void> pickAttachments(
|
||||||
|
{bool fromGallery = false, bool fromCamera = false}) async {
|
||||||
try {
|
try {
|
||||||
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
|
if (fromCamera) {
|
||||||
if (pickedFile != null) {
|
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
|
||||||
isProcessingAttachment.value = true;
|
if (pickedFile != null) {
|
||||||
File imageFile = File(pickedFile.path);
|
isProcessingAttachment.value = true;
|
||||||
File timestampedFile =
|
final timestamped = await TimestampImageHelper.addTimestamp(
|
||||||
await TimestampImageHelper.addTimestamp(imageFile: imageFile);
|
imageFile: File(pickedFile.path));
|
||||||
attachments.add(timestampedFile);
|
attachments.add(timestamped);
|
||||||
attachments.refresh();
|
}
|
||||||
|
} 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) {
|
} catch (e) {
|
||||||
_errorSnackbar("Camera error: $e");
|
_errorSnackbar("Attachment error: $e");
|
||||||
} finally {
|
} finally {
|
||||||
isProcessingAttachment.value = false;
|
isProcessingAttachment.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 🖼️ Pick from gallery
|
/// Selection handlers
|
||||||
Future<void> pickFromGallery() async {
|
void selectProject(Map<String, dynamic> project) =>
|
||||||
try {
|
selectedProject.value = project;
|
||||||
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) =>
|
void selectCategory(ExpenseCategory category) =>
|
||||||
selectedCategory.value = category;
|
selectedCategory.value = category;
|
||||||
void selectPayee(String payee) => selectedPayee.value = payee;
|
void selectPayee(String payee) => selectedPayee.value = payee;
|
||||||
void selectCurrency(Currency currency) => selectedCurrency.value = currency;
|
void selectCurrency(Currency currency) => selectedCurrency.value = currency;
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// 🔹 Attachment Helpers
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
void addAttachment(File file) => attachments.add(file);
|
void addAttachment(File file) => attachments.add(file);
|
||||||
void removeAttachment(File file) => attachments.remove(file);
|
void removeAttachment(File file) => attachments.remove(file);
|
||||||
void removeExistingAttachment(Map<String, dynamic> file) =>
|
|
||||||
existingAttachments.remove(file);
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
/// Build attachment payload
|
||||||
// 🔹 Payload Builder (for upload)
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
Future<List<Map<String, dynamic>>> buildAttachmentPayload() async {
|
Future<List<Map<String, dynamic>>> buildAttachmentPayload() async {
|
||||||
final existingPayload = existingAttachments
|
final existingPayload = existingAttachments
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
@ -234,27 +182,115 @@ class PaymentRequestController extends GetxController {
|
|||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final newPayload = await Future.wait(
|
final newPayload = await Future.wait(attachments.map((file) async {
|
||||||
attachments.map((file) async {
|
final bytes = await file.readAsBytes();
|
||||||
final bytes = await file.readAsBytes();
|
return {
|
||||||
return {
|
"fileName": file.path.split('/').last,
|
||||||
"fileName": file.path.split('/').last,
|
"base64Data": base64Encode(bytes),
|
||||||
"base64Data": base64Encode(bytes),
|
"contentType": lookupMimeType(file.path) ?? 'application/octet-stream',
|
||||||
"contentType":
|
"fileSize": await file.length(),
|
||||||
lookupMimeType(file.path) ?? 'application/octet-stream',
|
"description": "",
|
||||||
"fileSize": await file.length(),
|
};
|
||||||
"description": "",
|
}));
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return [...existingPayload, ...newPayload];
|
return [...existingPayload, ...newPayload];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
/// Submit payment request (Project API style)
|
||||||
// 🔹 Snackbar Helper
|
Future<bool> submitPaymentRequest() async {
|
||||||
// ─────────────────────────────────────────────
|
if (isSubmitting.value) return false;
|
||||||
void _errorSnackbar(String msg, [String title = "Error"]) {
|
|
||||||
|
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);
|
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
|
// Dashboard Module API Endpoints
|
||||||
static const String getDashboardAttendanceOverview =
|
static const String getDashboardAttendanceOverview =
|
||||||
"/dashboard/attendance-overview";
|
"/dashboard/attendance-overview";
|
||||||
|
static const String createExpensePaymentRequest =
|
||||||
|
"/expense/payment-request/create";
|
||||||
static const String getDashboardProjectProgress = "/dashboard/progression";
|
static const String getDashboardProjectProgress = "/dashboard/progression";
|
||||||
static const String getDashboardTasks = "/dashboard/tasks";
|
static const String getDashboardTasks = "/dashboard/tasks";
|
||||||
static const String getDashboardTeams = "/dashboard/teams";
|
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/currency_list_model.dart';
|
||||||
import 'package:marco/model/finance/payment_payee_request_model.dart';
|
import 'package:marco/model/finance/payment_payee_request_model.dart';
|
||||||
|
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
static const bool enableLogs = true;
|
static const bool enableLogs = true;
|
||||||
static const Duration extendedTimeout = Duration(seconds: 60);
|
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
|
/// Get Master Currencies
|
||||||
static Future<CurrencyListResponse?> getMasterCurrenciesApi() async {
|
static Future<CurrencyListResponse?> getMasterCurrenciesApi() async {
|
||||||
const endpoint = ApiEndpoints.getMasterCurrencies;
|
const endpoint = ApiEndpoints.getMasterCurrencies;
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// payment_request_bottom_sheet.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:marco/controller/finance/add_payment_request_controller.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/expense/expense_form_widgets.dart';
|
||||||
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
||||||
|
|
||||||
/// Show Payment Request Bottom Sheet
|
|
||||||
Future<T?> showPaymentRequestBottomSheet<T>({bool isEdit = false}) {
|
Future<T?> showPaymentRequestBottomSheet<T>({bool isEdit = false}) {
|
||||||
return Get.bottomSheet<T>(
|
return Get.bottomSheet<T>(
|
||||||
_PaymentRequestBottomSheet(isEdit: isEdit),
|
_PaymentRequestBottomSheet(isEdit: isEdit),
|
||||||
@ -19,7 +19,6 @@ Future<T?> showPaymentRequestBottomSheet<T>({bool isEdit = false}) {
|
|||||||
|
|
||||||
class _PaymentRequestBottomSheet extends StatefulWidget {
|
class _PaymentRequestBottomSheet extends StatefulWidget {
|
||||||
final bool isEdit;
|
final bool isEdit;
|
||||||
|
|
||||||
const _PaymentRequestBottomSheet({this.isEdit = false});
|
const _PaymentRequestBottomSheet({this.isEdit = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -29,223 +28,129 @@ class _PaymentRequestBottomSheet extends StatefulWidget {
|
|||||||
|
|
||||||
class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
|
class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
|
||||||
with UIMixin {
|
with UIMixin {
|
||||||
final PaymentRequestController controller =
|
final controller = Get.put(PaymentRequestController());
|
||||||
Get.put(PaymentRequestController());
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
final GlobalKey _projectDropdownKey = GlobalKey();
|
final _projectDropdownKey = GlobalKey();
|
||||||
final GlobalKey _categoryDropdownKey = GlobalKey();
|
final _categoryDropdownKey = GlobalKey();
|
||||||
final GlobalKey _payeeDropdownKey = GlobalKey();
|
final _currencyDropdownKey = GlobalKey();
|
||||||
final GlobalKey _currencyDropdownKey = GlobalKey();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Obx(
|
return Obx(() => Form(
|
||||||
() => Form(
|
key: _formKey,
|
||||||
key: _formKey,
|
child: BaseBottomSheet(
|
||||||
child: BaseBottomSheet(
|
title: widget.isEdit
|
||||||
title:
|
? "Edit Payment Request"
|
||||||
widget.isEdit ? "Edit Payment Request" : "Create Payment Request",
|
: "Create Payment Request",
|
||||||
isSubmitting: false,
|
isSubmitting: controller.isSubmitting.value,
|
||||||
onCancel: Get.back,
|
onCancel: Get.back,
|
||||||
onSubmit: () {
|
onSubmit: () async {
|
||||||
if (_formKey.currentState!.validate() && _validateSelections()) {
|
if (_formKey.currentState!.validate() && _validateSelections()) {
|
||||||
// Call your submit API here
|
final success = await controller.submitPaymentRequest();
|
||||||
showAppSnackbar(
|
if (success) {
|
||||||
title: "Success",
|
// First close the BottomSheet
|
||||||
message: "Payment request submitted!",
|
Get.back();
|
||||||
type: SnackbarType.success,
|
// Then show Snackbar
|
||||||
);
|
showAppSnackbar(
|
||||||
Get.back();
|
title: "Success",
|
||||||
}
|
message: "Payment request created successfully!",
|
||||||
},
|
type: SnackbarType.success,
|
||||||
child: SingleChildScrollView(
|
);
|
||||||
child: Column(
|
}
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
}
|
||||||
children: [
|
},
|
||||||
_buildDropdownField<String>(
|
child: SingleChildScrollView(
|
||||||
icon: Icons.work_outline,
|
child: Column(
|
||||||
title: " Select Project",
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
requiredField: true,
|
children: [
|
||||||
value: controller.selectedProject.value.isEmpty
|
_buildDropdown(
|
||||||
? "Select Project"
|
"Select Project",
|
||||||
: controller.selectedProject.value,
|
Icons.work_outline,
|
||||||
onTap: () => _showOptionList<String>(
|
controller.selectedProject.value?['name'] ??
|
||||||
controller.globalProjects.toList(),
|
"Select Project",
|
||||||
(p) => p,
|
controller.globalProjects,
|
||||||
controller.selectProject,
|
(p) => p['name'],
|
||||||
_projectDropdownKey,
|
controller.selectProject,
|
||||||
),
|
key: _projectDropdownKey),
|
||||||
dropdownKey: _projectDropdownKey,
|
_gap(),
|
||||||
),
|
_buildDropdown(
|
||||||
_gap(),
|
"Expense Category",
|
||||||
_buildDropdownField(
|
Icons.category_outlined,
|
||||||
icon: Icons.category_outlined,
|
controller.selectedCategory.value?.name ??
|
||||||
title: "Expense Category",
|
"Select Category",
|
||||||
requiredField: true,
|
controller.categories,
|
||||||
value: controller.selectedCategory.value?.name ??
|
|
||||||
"Select Category",
|
|
||||||
onTap: () => _showOptionList(
|
|
||||||
controller.categories.toList(),
|
|
||||||
(c) => c.name,
|
(c) => c.name,
|
||||||
controller.selectCategory,
|
controller.selectCategory,
|
||||||
_categoryDropdownKey),
|
key: _categoryDropdownKey),
|
||||||
dropdownKey: _categoryDropdownKey,
|
_gap(),
|
||||||
),
|
_buildTextField(
|
||||||
_gap(),
|
"Title", Icons.title_outlined, controller.titleController,
|
||||||
_buildTextField(
|
hint: "Enter title", validator: Validators.requiredField),
|
||||||
icon: Icons.title_outlined,
|
_gap(),
|
||||||
title: "Title",
|
_buildRadio("Is Advance Payment", Icons.attach_money_outlined,
|
||||||
controller: TextEditingController(),
|
controller.isAdvancePayment, ["Yes", "No"]),
|
||||||
hint: "Enter title",
|
_gap(),
|
||||||
validator: Validators.requiredField,
|
_buildDueDateField(),
|
||||||
),
|
_gap(),
|
||||||
_gap(),
|
_buildTextField("Amount", Icons.currency_rupee,
|
||||||
// Is Advance Payment Radio Buttons with Icon and Primary Color
|
controller.amountController,
|
||||||
Column(
|
hint: "Enter Amount",
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
keyboardType: TextInputType.number,
|
||||||
children: [
|
validator: (v) => (v != null &&
|
||||||
Row(
|
v.isNotEmpty &&
|
||||||
children: [
|
double.tryParse(v) != null)
|
||||||
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)
|
|
||||||
? null
|
? null
|
||||||
: "Enter valid amount",
|
: "Enter valid amount"),
|
||||||
),
|
_gap(),
|
||||||
_gap(),
|
_buildPayeeAutocompleteField(),
|
||||||
_buildDropdownField<String>(
|
_gap(),
|
||||||
icon: Icons.person_outline,
|
_buildDropdown(
|
||||||
title: "Payee",
|
"Currency",
|
||||||
requiredField: true,
|
Icons.monetization_on_outlined,
|
||||||
value: controller.selectedPayee.value.isEmpty
|
controller.selectedCurrency.value?.currencyName ??
|
||||||
? "Select Payee"
|
"Select Currency",
|
||||||
: controller.selectedPayee.value,
|
controller.currencies,
|
||||||
onTap: () => _showOptionList(controller.payees.toList(),
|
(c) => c.currencyName,
|
||||||
(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,
|
controller.selectCurrency,
|
||||||
_currencyDropdownKey),
|
key: _currencyDropdownKey),
|
||||||
dropdownKey: _currencyDropdownKey,
|
_gap(),
|
||||||
),
|
_buildTextField("Description", Icons.description_outlined,
|
||||||
_gap(),
|
controller.descriptionController,
|
||||||
_buildTextField(
|
hint: "Enter description",
|
||||||
icon: Icons.description_outlined,
|
maxLines: 3,
|
||||||
title: "Description",
|
validator: Validators.requiredField),
|
||||||
controller: TextEditingController(),
|
_gap(),
|
||||||
hint: "Enter description",
|
_buildAttachmentsSection(),
|
||||||
maxLines: 3,
|
],
|
||||||
validator: Validators.requiredField,
|
),
|
||||||
),
|
|
||||||
_gap(),
|
|
||||||
_buildAttachmentsSection(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
));
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _gap([double h = 16]) => MySpacing.height(h);
|
Widget _buildDropdown<T>(String title, IconData icon, String value,
|
||||||
|
List<T> options, String Function(T) getLabel, ValueChanged<T> onSelected,
|
||||||
Widget _buildDropdownField<T>({
|
{required GlobalKey key}) {
|
||||||
required IconData icon,
|
|
||||||
required String title,
|
|
||||||
required bool requiredField,
|
|
||||||
required String value,
|
|
||||||
required VoidCallback onTap,
|
|
||||||
required GlobalKey dropdownKey,
|
|
||||||
}) {
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SectionTitle(icon: icon, title: title, requiredField: requiredField),
|
SectionTitle(icon: icon, title: title, requiredField: true),
|
||||||
MySpacing.height(6),
|
MySpacing.height(6),
|
||||||
DropdownTile(key: dropdownKey, title: value, onTap: onTap),
|
DropdownTile(
|
||||||
|
key: key,
|
||||||
|
title: value,
|
||||||
|
onTap: () => _showOptionList(options, getLabel, onSelected, key)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTextField({
|
Widget _buildTextField(
|
||||||
required IconData icon,
|
String title, IconData icon, TextEditingController controller,
|
||||||
required String title,
|
{String? hint,
|
||||||
required TextEditingController controller,
|
TextInputType? keyboardType,
|
||||||
String? hint,
|
FormFieldValidator<String>? validator,
|
||||||
TextInputType? keyboardType,
|
int maxLines = 1}) {
|
||||||
FormFieldValidator<String>? validator,
|
|
||||||
int maxLines = 1,
|
|
||||||
}) {
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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() {
|
Widget _buildAttachmentsSection() {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SectionTitle(
|
const SectionTitle(
|
||||||
icon: Icons.attach_file,
|
icon: Icons.attach_file, title: "Attachments", requiredField: true),
|
||||||
title: "Attachments",
|
|
||||||
requiredField: true,
|
|
||||||
),
|
|
||||||
MySpacing.height(10),
|
MySpacing.height(10),
|
||||||
Obx(() {
|
Obx(() {
|
||||||
if (controller.isProcessingAttachment.value) {
|
if (controller.isProcessingAttachment.value) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(color: contentTheme.primary),
|
||||||
color: contentTheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text("Processing image, please wait...",
|
||||||
"Processing image, please wait...",
|
style:
|
||||||
style: TextStyle(
|
TextStyle(fontSize: 14, color: contentTheme.primary)),
|
||||||
fontSize: 14,
|
|
||||||
color: contentTheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -316,10 +357,9 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
|
|||||||
controller.existingAttachments.refresh();
|
controller.existingAttachments.refresh();
|
||||||
}
|
}
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: 'Removed',
|
title: 'Removed',
|
||||||
message: 'Attachment has been removed.',
|
message: 'Attachment has been removed.',
|
||||||
type: SnackbarType.success,
|
type: SnackbarType.success);
|
||||||
);
|
|
||||||
Navigator.pop(context);
|
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,
|
Future<void> _showOptionList<T>(List<T> options, String Function(T) getLabel,
|
||||||
ValueChanged<T> onSelected, GlobalKey key) async {
|
ValueChanged<T> onSelected, GlobalKey key) async {
|
||||||
if (options.isEmpty) {
|
if (options.isEmpty) {
|
||||||
@ -340,6 +381,22 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
|
|||||||
return;
|
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 =
|
final RenderBox button =
|
||||||
key.currentContext!.findRenderObject() as RenderBox;
|
key.currentContext!.findRenderObject() as RenderBox;
|
||||||
final RenderBox overlay =
|
final RenderBox overlay =
|
||||||
@ -349,17 +406,14 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
|
|||||||
final selected = await showMenu<T>(
|
final selected = await showMenu<T>(
|
||||||
context: context,
|
context: context,
|
||||||
position: RelativeRect.fromLTRB(
|
position: RelativeRect.fromLTRB(
|
||||||
position.dx,
|
position.dx,
|
||||||
position.dy + button.size.height,
|
position.dy + button.size.height,
|
||||||
overlay.size.width - position.dx - button.size.width,
|
overlay.size.width - position.dx - button.size.width,
|
||||||
0,
|
0),
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
items: options
|
items: options
|
||||||
.map((opt) => PopupMenuItem<T>(
|
.map(
|
||||||
value: opt,
|
(opt) => PopupMenuItem<T>(value: opt, child: Text(getLabel(opt))))
|
||||||
child: Text(getLabel(opt)),
|
|
||||||
))
|
|
||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -367,30 +421,21 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _validateSelections() {
|
bool _validateSelections() {
|
||||||
if (controller.selectedProject.value.isEmpty) {
|
if (controller.selectedProject.value == null ||
|
||||||
_showError("Please select a project");
|
controller.selectedProject.value!['id'].toString().isEmpty) {
|
||||||
return false;
|
return _showError("Please select a project");
|
||||||
}
|
|
||||||
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.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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showError(String msg) {
|
bool _showError(String msg) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error);
|
||||||
title: "Error",
|
return false;
|
||||||
message: msg,
|
|
||||||
type: SnackbarType.error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -116,7 +116,7 @@ class _FinanceScreenState extends State<FinanceScreen>
|
|||||||
showPaymentRequestBottomSheet();
|
showPaymentRequestBottomSheet();
|
||||||
},
|
},
|
||||||
backgroundColor: contentTheme.primary,
|
backgroundColor: contentTheme.primary,
|
||||||
child: const Icon(Icons.add),
|
child: Icon(Icons.add),
|
||||||
tooltip: "Create Payment Request",
|
tooltip: "Create Payment Request",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -81,6 +81,7 @@ dependencies:
|
|||||||
device_info_plus: ^11.3.0
|
device_info_plus: ^11.3.0
|
||||||
flutter_local_notifications: 19.4.0
|
flutter_local_notifications: 19.4.0
|
||||||
equatable: ^2.0.7
|
equatable: ^2.0.7
|
||||||
|
mime: ^2.0.0
|
||||||
|
|
||||||
timeline_tile: ^2.0.0
|
timeline_tile: ^2.0.0
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user