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