made chnages in request payment

This commit is contained in:
Vaibhav Surve 2025-11-06 12:21:53 +05:30
parent 42c2739d0c
commit 1a6ad4edfc
6 changed files with 531 additions and 390 deletions

View File

@ -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();
}
}

View File

@ -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";

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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",
),
);

View File

@ -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: