diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 32c0fa2..7fdd445 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -1,19 +1,21 @@ -import 'dart:io'; import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; +import 'dart:io'; + import 'package:file_picker/file_picker.dart'; -import 'package:geolocator/geolocator.dart'; +import 'package:flutter/material.dart'; import 'package:geocoding/geocoding.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:get/get.dart'; import 'package:mime/mime.dart'; + +import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/helpers/services/api_service.dart'; -import 'package:marco/model/expense/payment_types_model.dart'; -import 'package:marco/model/expense/expense_type_model.dart'; -import 'package:marco/model/expense/expense_status_model.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/model/employee_model.dart'; -import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:marco/model/expense/expense_status_model.dart'; +import 'package:marco/model/expense/expense_type_model.dart'; +import 'package:marco/model/expense/payment_types_model.dart'; class AddExpenseController extends GetxController { // === Text Controllers === @@ -23,35 +25,35 @@ class AddExpenseController extends GetxController { final transactionIdController = TextEditingController(); final gstController = TextEditingController(); final locationController = TextEditingController(); - final ExpenseController expenseController = Get.find(); + final transactionDateController = TextEditingController(); - // === Project Mapping === - final RxMap projectsMap = {}.obs; + // === State Controllers === + final RxBool isLoading = false.obs; + final RxBool isSubmitting = false.obs; + final RxBool isFetchingLocation = false.obs; // === Selected Models === final Rx selectedPaymentMode = Rx(null); final Rx selectedExpenseType = Rx(null); final Rx selectedExpenseStatus = Rx(null); - final RxString selectedProject = ''.obs; final Rx selectedPaidBy = Rx(null); - // === States === - final RxBool preApproved = false.obs; - final RxBool isFetchingLocation = false.obs; + final RxString selectedProject = ''.obs; final Rx selectedTransactionDate = Rx(null); - // === Master Data === + // === Lists === + final RxList attachments = [].obs; + final RxList globalProjects = [].obs; final RxList projects = [].obs; final RxList expenseTypes = [].obs; final RxList paymentModes = [].obs; final RxList expenseStatuses = [].obs; - final RxList globalProjects = [].obs; + final RxList allEmployees = [].obs; - // === Attachments === - final RxList attachments = [].obs; - RxList allEmployees = [].obs; - RxBool isLoading = false.obs; - final RxBool isSubmitting = false.obs; + // === Mappings === + final RxMap projectsMap = {}.obs; + + final ExpenseController expenseController = Get.find(); @override void onInit() { @@ -69,6 +71,7 @@ class AddExpenseController extends GetxController { transactionIdController.dispose(); gstController.dispose(); locationController.dispose(); + transactionDateController.dispose(); super.onClose(); } @@ -80,11 +83,10 @@ class AddExpenseController extends GetxController { allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'], allowMultiple: true, ); - if (result != null && result.paths.isNotEmpty) { - final newFiles = + final files = result.paths.whereType().map((e) => File(e)).toList(); - attachments.addAll(newFiles); + attachments.addAll(files); } } catch (e) { Get.snackbar("Error", "Failed to pick attachments: $e"); @@ -95,31 +97,22 @@ class AddExpenseController extends GetxController { attachments.remove(file); } - // === Fetch Master Data === - Future fetchMasterData() async { - try { - final expenseTypesData = await ApiService.getMasterExpenseTypes(); - if (expenseTypesData is List) { - expenseTypes.value = - expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); - } + // === Date Picker === + void pickTransactionDate(BuildContext context) async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: selectedTransactionDate.value ?? now, + firstDate: DateTime(now.year - 5), + lastDate: now, // ✅ Restrict future dates + ); - final paymentModesData = await ApiService.getMasterPaymentModes(); - if (paymentModesData is List) { - paymentModes.value = - paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); - } - - final expenseStatusData = await ApiService.getMasterExpenseStatus(); - if (expenseStatusData is List) { - expenseStatuses.value = expenseStatusData - .map((e) => ExpenseStatusModel.fromJson(e)) - .toList(); - } - } catch (e) { - Get.snackbar("Error", "Failed to fetch master data: $e"); + if (picked != null) { + selectedTransactionDate.value = picked; + transactionDateController.text = + "${picked.day.toString().padLeft(2, '0')}-${picked.month.toString().padLeft(2, '0')}-${picked.year}"; } - } + } // === Fetch Current Location === Future fetchCurrentLocation() async { @@ -143,26 +136,21 @@ class AddExpenseController extends GetxController { } final position = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.high, - ); - - final placemarks = await placemarkFromCoordinates( - position.latitude, - position.longitude, - ); + desiredAccuracy: LocationAccuracy.high); + final placemarks = + await placemarkFromCoordinates(position.latitude, position.longitude); if (placemarks.isNotEmpty) { final place = placemarks.first; - final addressParts = [ + final address = [ place.name, place.street, place.subLocality, place.locality, place.administrativeArea, place.country, - ].where((part) => part != null && part.isNotEmpty).toList(); - - locationController.text = addressParts.join(", "); + ].where((e) => e != null && e.isNotEmpty).join(", "); + locationController.text = address; } else { locationController.text = "${position.latitude}, ${position.longitude}"; } @@ -173,35 +161,33 @@ class AddExpenseController extends GetxController { } } - // === Submit Expense === // === Submit Expense === Future submitExpense() async { - if (isSubmitting.value) return; // Prevent multiple taps + if (isSubmitting.value) return; isSubmitting.value = true; try { - // === Validation === - List missingFields = []; + List missing = []; - if (selectedProject.value.isEmpty) missingFields.add("Project"); - if (selectedExpenseType.value == null) missingFields.add("Expense Type"); - if (selectedPaymentMode.value == null) missingFields.add("Payment Mode"); - if (selectedPaidBy.value == null) missingFields.add("Paid By"); - if (amountController.text.isEmpty) missingFields.add("Amount"); - if (supplierController.text.isEmpty) missingFields.add("Supplier Name"); - if (descriptionController.text.isEmpty) missingFields.add("Description"); - if (attachments.isEmpty) missingFields.add("Attachments"); + if (selectedProject.value.isEmpty) missing.add("Project"); + if (selectedExpenseType.value == null) missing.add("Expense Type"); + if (selectedPaymentMode.value == null) missing.add("Payment Mode"); + if (selectedPaidBy.value == null) missing.add("Paid By"); + if (amountController.text.isEmpty) missing.add("Amount"); + if (supplierController.text.isEmpty) missing.add("Supplier Name"); + if (descriptionController.text.isEmpty) missing.add("Description"); + if (attachments.isEmpty) missing.add("Attachments"); - if (missingFields.isNotEmpty) { + if (missing.isNotEmpty) { showAppSnackbar( title: "Missing Fields", - message: "Please provide: ${missingFields.join(', ')}.", + message: "Please provide: ${missing.join(', ')}.", type: SnackbarType.error, ); return; } - final double? amount = double.tryParse(amountController.text); + final amount = double.tryParse(amountController.text); if (amount == null) { showAppSnackbar( title: "Error", @@ -211,39 +197,46 @@ class AddExpenseController extends GetxController { return; } - final projectId = projectsMap[selectedProject.value]; - if (projectId == null) { + final selectedDate = selectedTransactionDate.value ?? DateTime.now(); + if (selectedDate.isAfter(DateTime.now())) { showAppSnackbar( - title: "Error", - message: "Invalid project selection.", + title: "Invalid Date", + message: "Transaction date cannot be in the future.", type: SnackbarType.error, ); return; } - // === Convert Attachments === - final attachmentData = await Future.wait(attachments.map((file) async { + final projectId = projectsMap[selectedProject.value]; + if (projectId == null) { + showAppSnackbar( + title: "Error", + message: "Invalid project selected.", + type: SnackbarType.error, + ); + return; + } + + final billAttachments = await Future.wait(attachments.map((file) async { final bytes = await file.readAsBytes(); - final base64String = base64Encode(bytes); - final mimeType = - lookupMimeType(file.path) ?? 'application/octet-stream'; - final fileSize = await file.length(); + final base64 = base64Encode(bytes); + final mime = lookupMimeType(file.path) ?? 'application/octet-stream'; + final size = await file.length(); return { "fileName": file.path.split('/').last, - "base64Data": base64String, - "contentType": mimeType, - "fileSize": fileSize, + "base64Data": base64, + "contentType": mime, + "fileSize": size, "description": "", }; - }).toList()); + })); - // === API Call === final success = await ApiService.createExpenseApi( projectId: projectId, expensesTypeId: selectedExpenseType.value!.id, paymentModeId: selectedPaymentMode.value!.id, - paidById: selectedPaidBy.value?.id ?? "", + paidById: selectedPaidBy.value!.id, transactionDate: (selectedTransactionDate.value ?? DateTime.now()).toUtc(), transactionId: transactionIdController.text, @@ -252,11 +245,11 @@ class AddExpenseController extends GetxController { supplerName: supplierController.text, amount: amount, noOfPersons: 0, - billAttachments: attachmentData, + billAttachments: billAttachments, ); if (success) { - await Get.find().fetchExpenses(); // 🔄 Refresh list + await expenseController.fetchExpenses(); Get.back(); showAppSnackbar( title: "Success", @@ -281,7 +274,33 @@ class AddExpenseController extends GetxController { } } - // === Fetch Projects === + // === Fetch Data Methods === + Future fetchMasterData() async { + try { + final expenseTypesData = await ApiService.getMasterExpenseTypes(); + final paymentModesData = await ApiService.getMasterPaymentModes(); + final expenseStatusData = await ApiService.getMasterExpenseStatus(); + + if (expenseTypesData is List) { + expenseTypes.value = + expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + } + + if (paymentModesData is List) { + paymentModes.value = + paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); + } + + if (expenseStatusData is List) { + expenseStatuses.value = expenseStatusData + .map((e) => ExpenseStatusModel.fromJson(e)) + .toList(); + } + } catch (e) { + Get.snackbar("Error", "Failed to fetch master data: $e"); + } + } + Future fetchGlobalProjects() async { try { final response = await ApiService.getGlobalProjects(); @@ -303,31 +322,24 @@ class AddExpenseController extends GetxController { } } - // === Fetch All Employees === Future fetchAllEmployees() async { isLoading.value = true; - try { final response = await ApiService.getAllEmployees(); if (response != null && response.isNotEmpty) { - allEmployees - .assignAll(response.map((json) => EmployeeModel.fromJson(json))); - logSafe( - "All Employees fetched for Manage Bucket: ${allEmployees.length}", - level: LogLevel.info, - ); + allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e))); + logSafe("All Employees fetched: ${allEmployees.length}", + level: LogLevel.info); } else { allEmployees.clear(); - logSafe("No employees found for Manage Bucket.", - level: LogLevel.warning); + logSafe("No employees found.", level: LogLevel.warning); } } catch (e) { allEmployees.clear(); - logSafe("Error fetching employees in Manage Bucket", - level: LogLevel.error, error: e); + logSafe("Error fetching employees", level: LogLevel.error, error: e); + } finally { + isLoading.value = false; + update(); } - - isLoading.value = false; - update(); } } diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index ac6e5af..07f0e23 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/expense/add_expense_controller.dart'; @@ -6,10 +8,7 @@ import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/expense_type_model.dart'; void showAddExpenseBottomSheet() { - Get.bottomSheet( - const _AddExpenseBottomSheet(), - isScrollControlled: true, - ); + Get.bottomSheet(const _AddExpenseBottomSheet(), isScrollControlled: true); } class _AddExpenseBottomSheet extends StatefulWidget { @@ -21,41 +20,67 @@ class _AddExpenseBottomSheet extends StatefulWidget { class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { final AddExpenseController controller = Get.put(AddExpenseController()); - final RxBool isProjectExpanded = false.obs; - void _showEmployeeList(BuildContext context) { - final employees = controller.allEmployees; + void _showEmployeeList() { showModalBottomSheet( context: context, backgroundColor: Colors.white, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (BuildContext context) { - return Obx(() { - return SizedBox( - height: 300, - child: ListView.builder( - itemCount: employees.length, - itemBuilder: (context, index) { - final emp = employees[index]; - final fullName = '${emp.firstName} ${emp.lastName}'.trim(); - - return ListTile( - title: Text(fullName.isNotEmpty ? fullName : "Unnamed"), - onTap: () { - controller.selectedPaidBy.value = emp; - Navigator.pop(context); - }, - ); - }, - ), - ); - }); - }, + borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (_) => Obx(() { + final employees = controller.allEmployees; + return SizedBox( + height: 300, + child: ListView.builder( + itemCount: employees.length, + itemBuilder: (_, index) { + final emp = employees[index]; + final fullName = '${emp.firstName} ${emp.lastName}'.trim(); + return ListTile( + title: Text(fullName.isNotEmpty ? fullName : "Unnamed"), + onTap: () { + controller.selectedPaidBy.value = emp; + Navigator.pop(context); + }, + ); + }, + ), + ); + }), ); } + 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 SafeArea( @@ -64,436 +89,279 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { child: Material( color: Colors.white, borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - child: Stack( - children: [ - Obx(() { - return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildDragHandle(), - Center( - child: MyText.titleLarge( - "Add Expense", - fontWeight: 700, - ), - ), - const SizedBox(height: 20), - - // Project Dropdown - const _SectionTitle( - icon: Icons.work_outline, - title: "Project", - requiredField: true, - ), - const SizedBox(height: 6), - Obx(() { - return _DropdownTile( - title: controller.selectedProject.value.isEmpty - ? "Select Project" - : controller.selectedProject.value, - onTap: () => _showOptionList( - context, - controller.globalProjects.toList(), - (p) => p, - (val) => controller.selectedProject.value = val, - ), - ); - }), - const SizedBox(height: 16), - - // Expense Type & GST - const _SectionTitle( - icon: Icons.category_outlined, - title: "Expense Type & GST No.", - requiredField: true, - ), - const SizedBox(height: 6), - Obx(() { - return _DropdownTile( - title: controller.selectedExpenseType.value?.name ?? - "Select Expense Type", - onTap: () => _showOptionList( - context, - controller.expenseTypes.toList(), - (e) => e.name, - (val) => controller.selectedExpenseType.value = val, - ), - ); - }), - const SizedBox(height: 8), - _CustomTextField( - controller: controller.gstController, - hint: "Enter GST No.", - ), - const SizedBox(height: 16), - - // Payment Mode - const _SectionTitle( - icon: Icons.payment, - title: "Payment Mode", - requiredField: true, - ), - const SizedBox(height: 6), - Obx(() { - return _DropdownTile( - title: controller.selectedPaymentMode.value?.name ?? - "Select Payment Mode", - onTap: () => _showOptionList( - context, - controller.paymentModes.toList(), - (m) => m.name, - (val) => controller.selectedPaymentMode.value = val, - ), - ); - }), - const SizedBox(height: 16), - - // Paid By - const _SectionTitle( - icon: Icons.person_outline, - title: "Paid By", - requiredField: true, - ), - const SizedBox(height: 6), - Obx(() { - final selected = controller.selectedPaidBy.value; - return GestureDetector( - onTap: () => _showEmployeeList(context), - 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.shade400), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - selected == null - ? "Select Paid By" - : '${selected.firstName} ${selected.lastName}', - style: const TextStyle(fontSize: 14), - ), - const Icon(Icons.arrow_drop_down, size: 22), - ], - ), - ), - ); - }), - const SizedBox(height: 16), - // Amount - const _SectionTitle( - icon: Icons.currency_rupee, - title: "Amount", - requiredField: true, - ), - const SizedBox(height: 6), - _CustomTextField( - controller: controller.amountController, - hint: "Enter Amount", - keyboardType: TextInputType.number, - ), - const SizedBox(height: 16), - - // Supplier Name - const _SectionTitle( - icon: Icons.store_mall_directory_outlined, - title: "Supplier Name", - requiredField: true, - ), - const SizedBox(height: 6), - _CustomTextField( - controller: controller.supplierController, - hint: "Enter Supplier Name", - ), - const SizedBox(height: 16), - - // Transaction ID - const _SectionTitle( - icon: Icons.confirmation_number_outlined, - title: "Transaction ID", - ), - const SizedBox(height: 6), - _CustomTextField( - controller: controller.transactionIdController, - hint: "Enter Transaction ID", - ), - const SizedBox(height: 16), - - // Location - const _SectionTitle( - icon: Icons.location_on_outlined, - title: "Location", - ), - const SizedBox(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, - ), - ), - ), - const SizedBox(height: 16), - - // Attachments Section - const _SectionTitle( - icon: Icons.attach_file, - title: "Attachments", - requiredField: true, - ), - const SizedBox(height: 6), - Obx(() { - return Wrap( - spacing: 8, - runSpacing: 8, - children: [ - ...controller.attachments.map((file) { - final fileName = file.path.split('/').last; - final extension = - fileName.split('.').last.toLowerCase(); - - final isImage = - ['jpg', 'jpeg', 'png'].contains(extension); - - IconData fileIcon; - Color iconColor = Colors.blueAccent; - 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; - default: - fileIcon = Icons.insert_drive_file; - iconColor = Colors.blueGrey; - } - - return Stack( - 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: () => - controller.removeAttachment(file), - ), - ), - ], - ); - }).toList(), - GestureDetector( - onTap: controller.pickAttachments, - 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), - ), - ), - ], - ); - }), - const SizedBox(height: 16), - - // Description - const _SectionTitle( - icon: Icons.description_outlined, - title: "Description", - requiredField: true, - ), - const SizedBox(height: 6), - _CustomTextField( - controller: controller.descriptionController, - hint: "Enter Description", - maxLines: 3, - ), - const SizedBox(height: 24), - - // Action Buttons - Row( + child: Obx(() { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _DragHandle(), + Center( + child: MyText.titleLarge("Add Expense", fontWeight: 700), + ), + const SizedBox(height: 20), + _buildSectionWithDropdown( + icon: Icons.work_outline, + title: "Project", + requiredField: true, + currentValue: controller.selectedProject.value.isEmpty + ? "Select Project" + : controller.selectedProject.value, + onTap: () => _showOptionList( + controller.globalProjects.toList(), + (p) => p, + (val) => controller.selectedProject.value = val), + ), + const SizedBox(height: 16), + _buildSectionWithDropdown( + icon: Icons.category_outlined, + title: "Expense Type", + requiredField: true, + currentValue: controller.selectedExpenseType.value?.name ?? + "Select Expense Type", + onTap: () => _showOptionList( + controller.expenseTypes.toList(), + (e) => e.name, + (val) => controller.selectedExpenseType.value = val, + ), + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.confirmation_number_outlined, + title: "GST No.", + ), + const SizedBox(height: 6), + _CustomTextField( + controller: controller.gstController, + hint: "Enter GST No.", + ), + const SizedBox(height: 16), + _buildSectionWithDropdown( + icon: Icons.payment, + title: "Payment Mode", + requiredField: true, + currentValue: controller.selectedPaymentMode.value?.name ?? + "Select Payment Mode", + onTap: () => _showOptionList( + controller.paymentModes.toList(), + (m) => m.name, + (val) => controller.selectedPaymentMode.value = val, + ), + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.person_outline, + title: "Paid By", + requiredField: true), + const SizedBox(height: 6), + GestureDetector( + onTap: _showEmployeeList, + child: _TileContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => Get.back(), - icon: const Icon(Icons.close, size: 18), - label: - MyText.bodyMedium("Cancel", fontWeight: 600), - style: OutlinedButton.styleFrom( + 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), + ], + ), + ), + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.currency_rupee, + title: "Amount", + requiredField: true), + const SizedBox(height: 6), + _CustomTextField( + controller: controller.amountController, + hint: "Enter Amount", + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.store_mall_directory_outlined, + title: "Supplier Name", + requiredField: true), + const SizedBox(height: 6), + _CustomTextField( + controller: controller.supplierController, + hint: "Enter Supplier Name", + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.confirmation_number_outlined, + title: "Transaction ID"), + const SizedBox(height: 6), + _CustomTextField( + controller: controller.transactionIdController, + hint: "Enter Transaction ID", + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.calendar_today, + title: "Transaction Date", + requiredField: true), + const SizedBox(height: 6), + GestureDetector( + onTap: () => controller.pickTransactionDate(context), + child: AbsorbPointer( + child: _CustomTextField( + controller: controller.transactionDateController, + hint: "Select Transaction Date", + ), + ), + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.location_on_outlined, title: "Location"), + const SizedBox(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, + ), + ), + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.attach_file, + title: "Attachments", + requiredField: true), + const SizedBox(height: 6), + _AttachmentsSection( + attachments: controller.attachments, + onRemove: controller.removeAttachment, + onAdd: controller.pickAttachments, + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.description_outlined, + title: "Description", + requiredField: true), + const SizedBox(height: 6), + _CustomTextField( + controller: controller.descriptionController, + hint: "Enter Description", + maxLines: 3, + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: Get.back, + icon: const Icon(Icons.close, size: 18), + label: MyText.bodyMedium("Cancel", fontWeight: 600), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(48)), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Obx( + () { + final isLoading = controller.isSubmitting.value; + return ElevatedButton.icon( + onPressed: + isLoading ? null : controller.submitExpense, + icon: isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.check, size: 18), + label: MyText.bodyMedium( + isLoading ? "Submitting..." : "Submit", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + padding: + const EdgeInsets.symmetric(vertical: 14), minimumSize: const Size.fromHeight(48), ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Obx(() { - final isLoading = controller.isSubmitting.value; - return ElevatedButton.icon( - onPressed: - isLoading ? null : controller.submitExpense, - icon: isLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.check, size: 18), - label: MyText.bodyMedium( - isLoading ? "Submitting..." : "Submit", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: - const EdgeInsets.symmetric(vertical: 14), - minimumSize: const Size.fromHeight(48), - ), - ); - }), - ), - ], - ) + ); + }, + ), + ), ], ), - ); - }), - ], - ), + ], + ), + ); + }), ), ), ); } - Future _showOptionList( - BuildContext context, - List options, - String Function(T) getLabel, - ValueChanged onSelected, - ) async { - final RenderBox button = context.findRenderObject() as RenderBox; - final RenderBox overlay = - Overlay.of(context).context.findRenderObject() as RenderBox; - - final Offset 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) { - return PopupMenuItem( - value: option, - child: Text(getLabel(option)), - ); - }).toList(), + Widget _buildSectionWithDropdown({ + required IconData icon, + required String title, + required bool requiredField, + required String currentValue, + required VoidCallback onTap, + Widget? extraWidget, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle(icon: icon, title: title, requiredField: requiredField), + const SizedBox(height: 6), + _DropdownTile(title: currentValue, onTap: onTap), + if (extraWidget != null) extraWidget, + ], ); - - if (selected != null) onSelected(selected); } +} - Widget _buildDragHandle() => Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), - ), +class _DragHandle extends StatelessWidget { + const _DragHandle(); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade400, + borderRadius: BorderRadius.circular(2), ), - ); + ), + ); + } } class _SectionTitle extends StatelessWidget { @@ -604,3 +472,140 @@ class _DropdownTile extends StatelessWidget { ); } } + +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 List attachments; + final ValueChanged onRemove; + final VoidCallback onAdd; + + const _AttachmentsSection({ + required this.attachments, + required this.onRemove, + required this.onAdd, + }); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...attachments.map((file) => + _AttachmentTile(file: file, onRemove: () => onRemove(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, + ), + ), + ], + ); + } +}