import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.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/widgets/my_spacing.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; Future showAddExpenseBottomSheet({ bool isEdit = false, Map? existingExpense, }) { return Get.bottomSheet( _AddExpenseBottomSheet( isEdit: isEdit, existingExpense: existingExpense, ), isScrollControlled: true, ); } 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()); void _showEmployeeList() async { await showModalBottomSheet( context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), backgroundColor: Colors.transparent, builder: (_) => EmployeeSelectorBottomSheet(), ); // Optional cleanup controller.employeeSearchController.clear(); controller.employeeSearchResults.clear(); } Future _showOptionList( List options, String Function(T) getLabel, ValueChanged onSelected, ) async { final button = context.findRenderObject() as RenderBox; final 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, position.dx + button.size.width, 0, ), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), items: options .map( (option) => PopupMenuItem( value: option, child: Text(getLabel(option)), ), ) .toList(), ); if (selected != null) onSelected(selected); } @override Widget build(BuildContext context) { return Obx(() { return BaseBottomSheet( title: widget.isEdit ? "Edit Expense" : "Add Expense", isSubmitting: controller.isSubmitting.value, onCancel: Get.back, onSubmit: () { if (!controller.isSubmitting.value) { controller.submitOrUpdateExpense(); } }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _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, ), ), MySpacing.height(16), _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, ), ), if (controller.selectedExpenseType.value?.noOfPersonsRequired == true) Padding( padding: const EdgeInsets.only(top: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _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, ), ], ), ), MySpacing.height(16), _SectionTitle( icon: Icons.confirmation_number_outlined, title: "GST No."), MySpacing.height(6), _CustomTextField( controller: controller.gstController, hint: "Enter GST No."), MySpacing.height(16), _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, ), ), MySpacing.height(16), _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), _SectionTitle( icon: Icons.currency_rupee, title: "Amount", requiredField: true), MySpacing.height(6), _CustomTextField( controller: controller.amountController, hint: "Enter Amount", keyboardType: TextInputType.number, ), MySpacing.height(16), _SectionTitle( icon: Icons.store_mall_directory_outlined, title: "Supplier Name", requiredField: true, ), MySpacing.height(6), _CustomTextField( controller: controller.supplierController, hint: "Enter Supplier Name"), MySpacing.height(16), _SectionTitle( icon: Icons.confirmation_number_outlined, title: "Transaction ID"), MySpacing.height(6), _CustomTextField( controller: controller.transactionIdController, hint: "Enter Transaction ID"), MySpacing.height(16), _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", ), ), ), MySpacing.height(16), _SectionTitle(icon: Icons.location_on_outlined, title: "Location"), MySpacing.height(6), TextField( 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), _SectionTitle( icon: Icons.attach_file, title: "Attachments", requiredField: true), MySpacing.height(6), _AttachmentsSection( attachments: controller.attachments, existingAttachments: controller.existingAttachments, onRemoveNew: controller.removeAttachment, onRemoveExisting: (item) { final index = controller.existingAttachments.indexOf(item); if (index != -1) { controller.existingAttachments[index]['isActive'] = false; controller.existingAttachments.refresh(); } }, onAdd: controller.pickAttachments, ), MySpacing.height(16), _SectionTitle( icon: Icons.description_outlined, title: "Description", requiredField: true), MySpacing.height(6), _CustomTextField( controller: controller.descriptionController, hint: "Enter Description", maxLines: 3, ), ], ), ); }); } Widget _buildDropdown({ required IconData icon, required String title, required bool requiredField, required String value, required VoidCallback onTap, }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle(icon: icon, title: title, requiredField: requiredField), MySpacing.height(6), _DropdownTile(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; const _CustomTextField({ required this.controller, required this.hint, this.maxLines = 1, this.keyboardType = TextInputType.text, }); @override Widget build(BuildContext context) { return TextField( controller: controller, maxLines: maxLines, keyboardType: keyboardType, 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, }); @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(); 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) { final imageDocs = activeExistingAttachments .where((d) => (d['contentType'] ?.toString() .startsWith('image/') ?? false)) .toList(); final initialIndex = imageDocs.indexWhere((d) => d == doc); showDialog( context: context, builder: (_) => ImageViewerDialog( imageSources: imageDocs.map((e) => e['url']).toList(), initialIndex: initialIndex, ), ); } else { 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 Wrap( spacing: 8, runSpacing: 8, children: [ ...attachments.map((file) => _AttachmentTile( file: file, onRemove: () => onRemoveNew(file), )), 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.add, 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, ), ), ], ); } }