import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/employee_selector_bottom_sheet.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/image_viewer_dialog.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; /// Show bottom sheet wrapper Future showAddExpenseBottomSheet({ bool isEdit = false, Map? existingExpense, }) { return Get.bottomSheet( _AddExpenseBottomSheet( isEdit: isEdit, existingExpense: existingExpense, ), isScrollControlled: true, ); } /// Bottom sheet widget class _AddExpenseBottomSheet extends StatefulWidget { final bool isEdit; final Map? existingExpense; const _AddExpenseBottomSheet({ this.isEdit = false, this.existingExpense, }); @override State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState(); } class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { final AddExpenseController controller = Get.put(AddExpenseController()); final _formKey = GlobalKey(); final GlobalKey _projectDropdownKey = GlobalKey(); final GlobalKey _expenseTypeDropdownKey = GlobalKey(); final GlobalKey _paymentModeDropdownKey = GlobalKey(); /// Show employee list Future _showEmployeeList() async { await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (_) => ReusableEmployeeSelectorBottomSheet( searchController: controller.employeeSearchController, searchResults: controller.employeeSearchResults, isSearching: controller.isSearchingEmployees, onSearch: controller.searchEmployees, onSelect: (emp) => controller.selectedPaidBy.value = emp, ), ); controller.employeeSearchController.clear(); controller.employeeSearchResults.clear(); } /// Generic option list Future _showOptionList( List options, String Function(T) getLabel, ValueChanged onSelected, GlobalKey triggerKey, ) async { final RenderBox button = triggerKey.currentContext!.findRenderObject() as RenderBox; final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox; final position = button.localToGlobal(Offset.zero, ancestor: overlay); final selected = await showMenu( context: context, position: RelativeRect.fromLTRB( position.dx, position.dy + button.size.height, overlay.size.width - position.dx - button.size.width, 0, ), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), items: options .map( (opt) => PopupMenuItem( value: opt, child: Text(getLabel(opt)), ), ) .toList(), ); if (selected != null) onSelected(selected); } @override Widget build(BuildContext context) { return Obx( () => Form( key: _formKey, child: BaseBottomSheet( title: widget.isEdit ? "Edit Expense" : "Add Expense", isSubmitting: controller.isSubmitting.value, onCancel: Get.back, onSubmit: () { if (_formKey.currentState!.validate()) { // Additional dropdown validation if (controller.selectedProject.value.isEmpty) { showAppSnackbar( title: "Error", message: "Please select a project", type: SnackbarType.error, ); return; } if (controller.selectedExpenseType.value == null) { showAppSnackbar( title: "Error", message: "Please select an expense type", type: SnackbarType.error, ); return; } if (controller.selectedPaymentMode.value == null) { showAppSnackbar( title: "Error", message: "Please select a payment mode", type: SnackbarType.error, ); return; } if (controller.selectedPaidBy.value == null) { showAppSnackbar( title: "Error", message: "Please select a person who paid", type: SnackbarType.error, ); return; } if (controller.attachments.isEmpty && controller.existingAttachments.isEmpty) { showAppSnackbar( title: "Error", message: "Please attach at least one document", type: SnackbarType.error, ); return; } // Validation passed, submit controller.submitOrUpdateExpense(); } else { showAppSnackbar( title: "Error", message: "Please fill all required fields correctly", type: SnackbarType.error, ); } }, child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 🔹 Project _buildDropdown( icon: Icons.work_outline, title: "Project", requiredField: true, value: controller.selectedProject.value.isEmpty ? "Select Project" : controller.selectedProject.value, onTap: () => _showOptionList( controller.globalProjects.toList(), (p) => p, (val) => controller.selectedProject.value = val, _projectDropdownKey, ), dropdownKey: _projectDropdownKey, ), MySpacing.height(16), // 🔹 Expense Type _buildDropdown( icon: Icons.category_outlined, title: "Expense Type", requiredField: true, value: controller.selectedExpenseType.value?.name ?? "Select Expense Type", onTap: () => _showOptionList( controller.expenseTypes.toList(), (e) => e.name, (val) => controller.selectedExpenseType.value = val, _expenseTypeDropdownKey, ), dropdownKey: _expenseTypeDropdownKey, ), // 🔹 Persons if required if (controller.selectedExpenseType.value?.noOfPersonsRequired == true) ...[ MySpacing.height(16), _SectionTitle( icon: Icons.people_outline, title: "No. of Persons", requiredField: true), MySpacing.height(6), _CustomTextField( controller: controller.noOfPersonsController, hint: "Enter No. of Persons", keyboardType: TextInputType.number, validator: Validators.requiredField, ), ], MySpacing.height(16), // 🔹 GST _SectionTitle( icon: Icons.confirmation_number_outlined, title: "GST No."), MySpacing.height(6), _CustomTextField( controller: controller.gstController, hint: "Enter GST No.", ), MySpacing.height(16), // 🔹 Payment Mode _buildDropdown( icon: Icons.payment, title: "Payment Mode", requiredField: true, value: controller.selectedPaymentMode.value?.name ?? "Select Payment Mode", onTap: () => _showOptionList( controller.paymentModes.toList(), (p) => p.name, (val) => controller.selectedPaymentMode.value = val, _paymentModeDropdownKey, ), dropdownKey: _paymentModeDropdownKey, ), MySpacing.height(16), // 🔹 Paid By _SectionTitle( icon: Icons.person_outline, title: "Paid By", requiredField: true), MySpacing.height(6), GestureDetector( onTap: _showEmployeeList, child: _TileContainer( child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( controller.selectedPaidBy.value == null ? "Select Paid By" : '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}', style: const TextStyle(fontSize: 14), ), const Icon(Icons.arrow_drop_down, size: 22), ], ), ), ), MySpacing.height(16), // 🔹 Amount _SectionTitle( icon: Icons.currency_rupee, title: "Amount", requiredField: true), MySpacing.height(6), _CustomTextField( controller: controller.amountController, hint: "Enter Amount", keyboardType: TextInputType.number, validator: (v) => Validators.isNumeric(v ?? "") ? null : "Enter valid amount", ), MySpacing.height(16), // 🔹 Supplier _SectionTitle( icon: Icons.store_mall_directory_outlined, title: "Supplier Name/Transporter Name/Other", requiredField: true, ), MySpacing.height(6), _CustomTextField( controller: controller.supplierController, hint: "Enter Supplier Name/Transporter Name or Other", validator: Validators.nameValidator, ), MySpacing.height(16), // 🔹 Transaction ID _SectionTitle( icon: Icons.confirmation_number_outlined, title: "Transaction ID"), MySpacing.height(6), _CustomTextField( controller: controller.transactionIdController, hint: "Enter Transaction ID", validator: (value) { if (value != null && value.isNotEmpty) { return Validators.transactionIdValidator(value); } return null; }, ), MySpacing.height(16), // 🔹 Transaction Date _SectionTitle( icon: Icons.calendar_today, title: "Transaction Date", requiredField: true), MySpacing.height(6), GestureDetector( onTap: () => controller.pickTransactionDate(context), child: AbsorbPointer( child: _CustomTextField( controller: controller.transactionDateController, hint: "Select Transaction Date", validator: Validators.requiredField, ), ), ), MySpacing.height(16), // 🔹 Location _SectionTitle( icon: Icons.location_on_outlined, title: "Location"), MySpacing.height(6), TextFormField( controller: controller.locationController, decoration: InputDecoration( hintText: "Enter Location", filled: true, fillColor: Colors.grey.shade100, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8)), contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), suffixIcon: controller.isFetchingLocation.value ? const Padding( padding: EdgeInsets.all(12), child: SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ), ) : IconButton( icon: const Icon(Icons.my_location), tooltip: "Use Current Location", onPressed: controller.fetchCurrentLocation, ), ), ), MySpacing.height(16), // 🔹 Attachments _SectionTitle( icon: Icons.attach_file, title: "Attachments", requiredField: true), MySpacing.height(6), _AttachmentsSection( attachments: controller.attachments, existingAttachments: controller.existingAttachments, onRemoveNew: controller.removeAttachment, 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, ), MySpacing.height(16), // 🔹 Description _SectionTitle( icon: Icons.description_outlined, title: "Description", requiredField: true), MySpacing.height(6), _CustomTextField( controller: controller.descriptionController, hint: "Enter Description", maxLines: 3, validator: Validators.requiredField, ), ], ), ), ), ), ); } Widget _buildDropdown({ required IconData icon, required String title, required bool requiredField, required String value, required VoidCallback onTap, required GlobalKey dropdownKey, }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle(icon: icon, title: title, requiredField: requiredField), MySpacing.height(6), _DropdownTile(key: dropdownKey, title: value, onTap: onTap), ], ); } } class _SectionTitle extends StatelessWidget { final IconData icon; final String title; final bool requiredField; const _SectionTitle({ required this.icon, required this.title, this.requiredField = false, }); @override Widget build(BuildContext context) { final color = Colors.grey[700]; return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon(icon, color: color, size: 18), const SizedBox(width: 8), RichText( text: TextSpan( style: DefaultTextStyle.of(context).style.copyWith( fontWeight: FontWeight.w600, color: Colors.black87, ), children: [ TextSpan(text: title), if (requiredField) const TextSpan( text: ' *', style: TextStyle(color: Colors.red), ), ], ), ), ], ); } } class _CustomTextField extends StatelessWidget { final TextEditingController controller; final String hint; final int maxLines; final TextInputType keyboardType; final String? Function(String?)? validator; // 👈 for validation const _CustomTextField({ required this.controller, required this.hint, this.maxLines = 1, this.keyboardType = TextInputType.text, this.validator, }); @override Widget build(BuildContext context) { return TextFormField( controller: controller, maxLines: maxLines, keyboardType: keyboardType, validator: validator, // 👈 applied decoration: InputDecoration( hintText: hint, hintStyle: TextStyle(fontSize: 14, color: Colors.grey[600]), 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), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.grey.shade300), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.blueAccent, width: 1.5), ), ), ); } } class _DropdownTile extends StatelessWidget { final String title; final VoidCallback onTap; const _DropdownTile({ required this.title, required this.onTap, Key? key, // Add optional key parameter }) : super(key: key); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), decoration: BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey.shade300), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text( title, style: const TextStyle(fontSize: 14, color: Colors.black87), overflow: TextOverflow.ellipsis, ), ), const Icon(Icons.arrow_drop_down), ], ), ), ); } } class _TileContainer extends StatelessWidget { final Widget child; const _TileContainer({required this.child}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), decoration: BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey.shade400), ), child: child, ); } } class _AttachmentsSection extends StatelessWidget { final RxList attachments; final RxList> existingAttachments; final ValueChanged onRemoveNew; final ValueChanged>? onRemoveExisting; final VoidCallback onAdd; const _AttachmentsSection({ required this.attachments, required this.existingAttachments, required this.onRemoveNew, this.onRemoveExisting, required this.onAdd, }); @override Widget build(BuildContext context) { return Obx(() { final activeExistingAttachments = existingAttachments.where((doc) => doc['isActive'] != false).toList(); // Allowed image extensions for local files final allowedImageExtensions = ['jpg', 'jpeg', 'png']; // To show all new attachments in UI but filter only images for dialog final imageFiles = attachments.where((file) { final extension = file.path.split('.').last.toLowerCase(); return allowedImageExtensions.contains(extension); }).toList(); // Filter existing attachments to only images (for dialog) final imageExistingAttachments = activeExistingAttachments .where((d) => (d['contentType']?.toString().startsWith('image/') ?? false)) .toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (activeExistingAttachments.isNotEmpty) ...[ Text( "Existing Attachments", style: const TextStyle(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: activeExistingAttachments.map((doc) { final isImage = doc['contentType']?.toString().startsWith('image/') ?? false; final url = doc['url']; final fileName = doc['fileName'] ?? 'Unnamed'; return Stack( clipBehavior: Clip.none, children: [ GestureDetector( onTap: () async { if (isImage) { // Open dialog only with image attachments (URLs) final imageSources = imageExistingAttachments .map((e) => e['url']) .toList(); final initialIndex = imageExistingAttachments .indexWhere((d) => d == doc); showDialog( context: context, builder: (_) => ImageViewerDialog( imageSources: imageSources, initialIndex: initialIndex, ), ); } else { // Open non-image attachment externally or show error if (url != null && await canLaunchUrlString(url)) { await launchUrlString( url, mode: LaunchMode.externalApplication, ); } else { showAppSnackbar( title: 'Error', message: 'Could not open the document.', type: SnackbarType.error, ); } } }, child: Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(6), color: Colors.grey.shade100, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( isImage ? Icons.image : Icons.insert_drive_file, size: 20, color: Colors.grey[600], ), const SizedBox(width: 7), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 120), child: Text( fileName, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12), ), ), ], ), ), ), if (onRemoveExisting != null) Positioned( top: -6, right: -6, child: IconButton( icon: const Icon(Icons.close, color: Colors.red, size: 18), onPressed: () { onRemoveExisting?.call(doc); }, ), ), ], ); }).toList(), ), const SizedBox(height: 16), ], // New attachments section: show all files, but only open dialog for images Wrap( spacing: 8, runSpacing: 8, children: [ ...attachments.map((file) { final extension = file.path.split('.').last.toLowerCase(); final isImage = allowedImageExtensions.contains(extension); return GestureDetector( onTap: () { if (isImage) { // Show dialog only for image files final initialIndex = imageFiles.indexOf(file); showDialog( context: context, builder: (_) => ImageViewerDialog( imageSources: imageFiles, initialIndex: initialIndex, ), ); } else { // For non-image, you can show snackbar or do nothing or handle differently showAppSnackbar( title: 'Info', message: 'Preview for this file type is not supported.', type: SnackbarType.info, ); } }, child: _AttachmentTile( file: file, onRemove: () => onRemoveNew(file), ), ); }), // 📎 File Picker Button GestureDetector( onTap: onAdd, child: Container( width: 80, height: 80, decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade400), borderRadius: BorderRadius.circular(8), color: Colors.grey.shade100, ), child: const Icon(Icons.attach_file, size: 30, color: Colors.grey), ), ), // 📷 Camera Button GestureDetector( onTap: () => Get.find().pickFromCamera(), child: Container( width: 80, height: 80, decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade400), borderRadius: BorderRadius.circular(8), color: Colors.grey.shade100, ), child: const Icon(Icons.camera_alt, size: 30, color: Colors.grey), ), ), ], ), ], ); }); } } class _AttachmentTile extends StatelessWidget { final File file; final VoidCallback onRemove; const _AttachmentTile({required this.file, required this.onRemove}); @override Widget build(BuildContext context) { final fileName = file.path.split('/').last; final extension = fileName.split('.').last.toLowerCase(); final isImage = ['jpg', 'jpeg', 'png'].contains(extension); IconData fileIcon = Icons.insert_drive_file; Color iconColor = Colors.blueGrey; switch (extension) { case 'pdf': fileIcon = Icons.picture_as_pdf; iconColor = Colors.redAccent; break; case 'doc': case 'docx': fileIcon = Icons.description; iconColor = Colors.blueAccent; break; case 'xls': case 'xlsx': fileIcon = Icons.table_chart; iconColor = Colors.green; break; case 'txt': fileIcon = Icons.article; iconColor = Colors.grey; break; } return Stack( clipBehavior: Clip.none, children: [ Container( width: 80, height: 80, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey.shade300), color: Colors.grey.shade100, ), child: isImage ? ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.file(file, fit: BoxFit.cover), ) : Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(fileIcon, color: iconColor, size: 30), const SizedBox(height: 4), Text( extension.toUpperCase(), style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, color: iconColor), overflow: TextOverflow.ellipsis, ), ], ), ), Positioned( top: -6, right: -6, child: IconButton( icon: const Icon(Icons.close, color: Colors.red, size: 18), onPressed: onRemove, ), ), ], ); } }