marco.pms.mobileapp/lib/model/finance/add_payment_request_bottom_sheet.dart

599 lines
21 KiB
Dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/finance/add_payment_request_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/validators.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/model/employees/multiple_select_bottomsheet.dart';
import 'package:marco/model/employees/employee_model.dart';
Future<T?> showPaymentRequestBottomSheet<T>({
bool isEdit = false,
Map<String, dynamic>? existingData,
VoidCallback? onUpdated,
}) {
return Get.bottomSheet<T>(
_PaymentRequestBottomSheet(
isEdit: isEdit,
existingData: existingData,
onUpdated: onUpdated,
),
isScrollControlled: true,
);
}
class _PaymentRequestBottomSheet extends StatefulWidget {
final bool isEdit;
final Map<String, dynamic>? existingData;
final VoidCallback? onUpdated;
const _PaymentRequestBottomSheet({
this.isEdit = false,
this.existingData,
this.onUpdated,
});
@override
State<_PaymentRequestBottomSheet> createState() =>
_PaymentRequestBottomSheetState();
}
class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
with UIMixin {
final controller = Get.put(AddPaymentRequestController());
final _formKey = GlobalKey<FormState>();
final _projectDropdownKey = GlobalKey();
final _categoryDropdownKey = GlobalKey();
final _currencyDropdownKey = GlobalKey();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (widget.isEdit && widget.existingData != null) {
final data = widget.existingData!;
controller.titleController.text = data["title"] ?? "";
controller.amountController.text = data["amount"]?.toString() ?? "";
controller.descriptionController.text = data["description"] ?? "";
if (data["dueDate"] != null && data["dueDate"].toString().isNotEmpty) {
DateTime? dueDate = DateTime.tryParse(data["dueDate"].toString());
if (dueDate != null) {
controller.selectedDueDate.value = dueDate;
controller.dueDateController.text =
DateFormat('dd MMM yyyy').format(dueDate);
}
}
controller.selectedProject.value = {
'id': data["projectId"],
'name': data["projectName"],
};
controller.selectedPayee.value = data["payee"] ?? "";
controller.isAdvancePayment.value = data["isAdvancePayment"] ?? false;
everAll([controller.categories, controller.currencies], (_) {
controller.selectedCategory.value = controller.categories
.firstWhereOrNull((c) => c.id == data["expenseCategoryId"]);
controller.selectedCurrency.value = controller.currencies
.firstWhereOrNull((c) => c.id == data["currencyId"]);
});
final attachmentsData = data["attachments"];
if (attachmentsData != null &&
attachmentsData is List &&
attachmentsData.isNotEmpty) {
final attachments = attachmentsData
.whereType<Map<String, dynamic>>()
.map((a) => {
"id": a["documentId"] ?? a["id"],
"fileName": a["fileName"],
"url": a["url"],
"thumbUrl": a["thumbUrl"],
"fileSize": a["fileSize"] ?? 0,
"contentType": a["contentType"] ?? "",
"isActive": true,
})
.toList();
controller.existingAttachments.assignAll(attachments);
} else {
controller.existingAttachments.clear();
}
}
});
}
@override
Widget build(BuildContext context) {
return Obx(() => SafeArea(
child: Form(
key: _formKey,
child: BaseBottomSheet(
title: widget.isEdit
? "Edit Payment Request"
: "Create Payment Request",
isSubmitting: controller.isSubmitting.value,
onCancel: Get.back,
submitText: "Save as Draft",
onSubmit: () async {
if (_formKey.currentState!.validate() &&
_validateSelections()) {
bool success = false;
if (widget.isEdit && widget.existingData != null) {
final requestId =
widget.existingData!['id']?.toString() ?? '';
if (requestId.isNotEmpty) {
success = await controller.submitEditedPaymentRequest(
requestId: requestId);
} else {
_showError("Invalid Payment Request ID");
return;
}
} else {
success = await controller.submitPaymentRequest();
}
return Obx(() => SafeArea(
child: Form(
key: _formKey,
child: BaseBottomSheet(
title: widget.isEdit
? "Edit Payment Request"
: "Create Payment Request",
isSubmitting: controller.isSubmitting.value,
onCancel: Get.back,
submitText: "Save as Draft",
onSubmit: () async {
if (_formKey.currentState!.validate() &&
_validateSelections()) {
bool success = false;
if (widget.isEdit && widget.existingData != null) {
final requestId =
widget.existingData!['id']?.toString() ?? '';
if (requestId.isNotEmpty) {
success = await controller.submitEditedPaymentRequest(
requestId: requestId);
} else {
_showError("Invalid Payment Request ID");
return;
}
} else {
success = await controller.submitPaymentRequest();
}
if (success) {
Get.back();
widget.onUpdated?.call();
if (success) {
Get.back();
widget.onUpdated?.call();
showAppSnackbar(
title: "Success",
message: widget.isEdit
? "Payment request updated successfully!"
: "Payment request created successfully!",
type: SnackbarType.success,
);
}
}
},
child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildDropdown(
showAppSnackbar(
title: "Success",
message: widget.isEdit
? "Payment request updated successfully!"
: "Payment request created successfully!",
type: SnackbarType.success,
);
}
}
},
child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildDropdown(
"Select Project",
Icons.work_outline,
controller.selectedProject.value?['name'] ??
"Select Project",
controller.globalProjects,
(p) => p['name'],
controller.selectProject,
key: _projectDropdownKey,
),
_gap(),
_buildDropdown(
key: _projectDropdownKey,
),
_gap(),
_buildDropdown(
"Expense Category",
Icons.category_outlined,
controller.selectedCategory.value?.name ??
"Select Category",
controller.categories,
(c) => c.name,
controller.selectCategory,
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(),
_buildPayeeField(),
_gap(),
_buildDropdown(
"Currency",
Icons.monetization_on_outlined,
controller.selectedCurrency.value?.currencyName ??
"Select Currency",
controller.currencies,
(c) => c.currencyName,
controller.selectCurrency,
key: _currencyDropdownKey,
),
_gap(),
_buildTextField("Description", Icons.description_outlined,
controller.descriptionController,
hint: "Enter description",
maxLines: 3,
validator: Validators.requiredField),
_gap(),
_buildAttachmentsSection(),
MySpacing.height(30),
],
),
key: _currencyDropdownKey,
),
_gap(),
_buildTextField("Description", Icons.description_outlined,
controller.descriptionController,
hint: "Enter description",
maxLines: 3,
validator: Validators.requiredField),
_gap(),
_buildAttachmentsSection(),
MySpacing.height(30),
],
),
),
),
),
));
}
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: true),
MySpacing.height(6),
DropdownTile(
key: key,
title: value,
onTap: () => _showOptionList(options, getLabel, onSelected, key)),
],
);
}
Widget _buildTextField(
String title, IconData icon, TextEditingController controller,
{String? hint,
TextInputType? keyboardType,
FormFieldValidator<String>? validator,
int maxLines = 1}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(
icon: icon, title: title, requiredField: validator != null),
MySpacing.height(6),
CustomTextField(
controller: controller,
hint: hint ?? "",
keyboardType: keyboardType ?? TextInputType.text,
validator: validator,
maxLines: maxLines,
),
],
);
}
Widget _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 _buildPayeeField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(
icon: Icons.person_outline,
title: "Payee",
requiredField: true,
),
MySpacing.height(6),
GestureDetector(
onTap: _showPayeeSelector,
child: TileContainer(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Obx(() => Text(
controller.selectedPayee.value?.name ?? "Select Payee",
style: const TextStyle(fontSize: 15),
overflow: TextOverflow.ellipsis,
)),
),
const Icon(Icons.arrow_drop_down, size: 22),
],
),
),
),
const SizedBox(height: 6),
],
);
}
Widget _buildAttachmentsSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(
icon: Icons.attach_file, title: "Attachments", requiredField: true),
MySpacing.height(10),
Obx(() {
if (controller.isProcessingAttachment.value) {
return Center(
child: Column(
children: [
CircularProgressIndicator(color: contentTheme.primary),
const SizedBox(height: 8),
Text("Processing image, please wait...",
style:
TextStyle(fontSize: 14, color: contentTheme.primary)),
],
),
);
}
return AttachmentsSection(
attachments: controller.attachments,
existingAttachments: controller.existingAttachments,
onRemoveNew: controller.removeAttachment,
controller: controller,
onRemoveExisting: (item) async {
await showDialog(
context: context,
barrierDismissible: false,
builder: (_) => ConfirmDialog(
title: "Remove Attachment",
message: "Are you sure you want to remove this attachment?",
confirmText: "Remove",
icon: Icons.delete,
confirmColor: Colors.redAccent,
onConfirm: () async {
final index = controller.existingAttachments.indexOf(item);
if (index != -1) {
controller.existingAttachments[index]['isActive'] = false;
controller.existingAttachments.refresh();
}
showAppSnackbar(
title: 'Removed',
message: 'Attachment has been removed.',
type: SnackbarType.success);
},
),
);
},
onAdd: controller.pickAttachments,
);
}),
],
);
}
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) {
_showError("No options available");
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 =
Overlay.of(context).context.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero, ancestor: overlay);
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),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
items: options
.map(
(opt) => PopupMenuItem<T>(value: opt, child: Text(getLabel(opt))))
.toList(),
);
if (selected != null) onSelected(selected);
}
bool _validateSelections() {
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 == null) {
return _showError("Please select a payee");
}
if (controller.selectedCurrency.value == null) {
return _showError("Please select currency");
}
return true;
}
Future<void> _showPayeeSelector() async {
final result = await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => EmployeeSelectionBottomSheet(
title: "Select Payee",
multipleSelection: false,
initiallySelected: controller.selectedPayee.value != null
? [controller.selectedPayee.value!]
: [],
),
);
if (result is EmployeeModel) {
controller.selectedPayee.value = result;
}
}
bool _showError(String msg) {
showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error);
return false;
}
}