Refactor UserDocumentsPage: Enhance UI with search bar, filter chips, and document cards; implement infinite scrolling and FAB for document upload; improve state management and permission checks.

This commit is contained in:
Vaibhav Surve 2025-10-31 14:58:15 +05:30
parent 62eb7b1d97
commit bc9fc4d6f1
7 changed files with 1473 additions and 1037 deletions

View File

@ -8,6 +8,7 @@ import 'package:intl/intl.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_image_compressor.dart';
import 'package:marco/helpers/widgets/time_stamp_image_helper.dart';
import 'package:marco/model/attendance/attendance_model.dart';
import 'package:marco/model/project_model.dart';
@ -32,7 +33,7 @@ class AttendanceController extends GetxController {
final isLoadingOrganizations = false.obs;
// States
String selectedTab = 'todaysAttendance';
String selectedTab = 'todaysAttendance';
DateTime? startDateAttendance;
DateTime? endDateAttendance;
@ -104,7 +105,7 @@ String selectedTab = 'todaysAttendance';
.toList();
}
// Computed filtered regularization logs
// Computed filtered regularization logs
List<RegularizationLogModel> get filteredRegularizationLogs {
if (searchQuery.value.isEmpty) return regularizationLogs;
return regularizationLogs
@ -174,8 +175,12 @@ String selectedTab = 'todaysAttendance';
return false;
}
// 🔹 Add timestamp to the image
final timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: File(image.path));
final compressedBytes =
await compressImageToUnder100KB(File(image.path));
await compressImageToUnder100KB(timestampedFile);
if (compressedBytes == null) {
logSafe("Image compression failed.", level: LogLevel.error);
return false;

View File

@ -5,54 +5,63 @@ import 'package:marco/model/document/document_filter_model.dart';
import 'package:marco/model/document/documents_list_model.dart';
class DocumentController extends GetxController {
// ------------------ Observables ---------------------
var isLoading = false.obs;
var documents = <DocumentItem>[].obs;
var filters = Rxn<DocumentFiltersData>();
// ==================== Observables ====================
final isLoading = false.obs;
final documents = <DocumentItem>[].obs;
final filters = Rxn<DocumentFiltersData>();
// Selected filters (multi-select support)
var selectedUploadedBy = <String>[].obs;
var selectedCategory = <String>[].obs;
var selectedType = <String>[].obs;
var selectedTag = <String>[].obs;
// Selected filters (multi-select)
final selectedUploadedBy = <String>[].obs;
final selectedCategory = <String>[].obs;
final selectedType = <String>[].obs;
final selectedTag = <String>[].obs;
// Pagination state
var pageNumber = 1.obs;
final int pageSize = 20;
var hasMore = true.obs;
// Pagination
final pageNumber = 1.obs;
final pageSize = 20;
final hasMore = true.obs;
// Error message
var errorMessage = "".obs;
// Error handling
final errorMessage = ''.obs;
// NEW: show inactive toggle
var showInactive = false.obs;
// Preferences
final showInactive = false.obs;
// NEW: search
var searchQuery = ''.obs;
var searchController = TextEditingController();
// New filter fields
var isUploadedAt = true.obs;
var isVerified = RxnBool();
var startDate = Rxn<String>();
var endDate = Rxn<String>();
// Search
final searchQuery = ''.obs;
final searchController = TextEditingController();
// ------------------ API Calls -----------------------
// Additional filters
final isUploadedAt = true.obs;
final isVerified = RxnBool();
final startDate = Rxn<String>();
final endDate = Rxn<String>();
/// Fetch Document Filters for an Entity
// ==================== Lifecycle ====================
@override
void onClose() {
// Don't dispose searchController here - it's managed by the page
super.onClose();
}
// ==================== API Methods ====================
/// Fetch document filters for entity
Future<void> fetchFilters(String entityTypeId) async {
try {
isLoading.value = true;
final response = await ApiService.getDocumentFilters(entityTypeId);
if (response != null && response.success) {
filters.value = response.data;
} else {
errorMessage.value = response?.message ?? "Failed to fetch filters";
errorMessage.value = response?.message ?? 'Failed to fetch filters';
_showError('Failed to load filters');
}
} catch (e) {
errorMessage.value = "Error fetching filters: $e";
} finally {
isLoading.value = false;
errorMessage.value = 'Error fetching filters: $e';
_showError('Error loading filters');
debugPrint('❌ Error fetching filters: $e');
}
}
@ -65,11 +74,14 @@ class DocumentController extends GetxController {
}) async {
try {
isLoading.value = true;
final success =
await ApiService.deleteDocumentApi(id: id, isActive: isActive);
final success = await ApiService.deleteDocumentApi(
id: id,
isActive: isActive,
);
if (success) {
// 🔥 Always fetch fresh list after toggle
// Refresh list after state change
await fetchDocuments(
entityTypeId: entityTypeId,
entityId: entityId,
@ -77,41 +89,19 @@ class DocumentController extends GetxController {
);
return true;
} else {
errorMessage.value = "Failed to update document state";
errorMessage.value = 'Failed to update document state';
return false;
}
} catch (e) {
errorMessage.value = "Error updating document: $e";
errorMessage.value = 'Error updating document: $e';
debugPrint('❌ Error toggling document state: $e');
return false;
} finally {
isLoading.value = false;
}
}
/// Permanently delete a document (or deactivate depending on API)
Future<bool> deleteDocument(String id, {bool isActive = false}) async {
try {
isLoading.value = true;
final success =
await ApiService.deleteDocumentApi(id: id, isActive: isActive);
if (success) {
// remove from local list immediately for better UX
documents.removeWhere((doc) => doc.id == id);
return true;
} else {
errorMessage.value = "Failed to delete document";
return false;
}
} catch (e) {
errorMessage.value = "Error deleting document: $e";
return false;
} finally {
isLoading.value = false;
}
}
/// Fetch Documents for an entity
/// Fetch documents for entity with pagination
Future<void> fetchDocuments({
required String entityTypeId,
required String entityId,
@ -120,20 +110,25 @@ class DocumentController extends GetxController {
bool reset = false,
}) async {
try {
// Reset pagination if needed
if (reset) {
pageNumber.value = 1;
documents.clear();
hasMore.value = true;
}
if (!hasMore.value) return;
// Don't fetch if no more data
if (!hasMore.value && !reset) return;
// Prevent duplicate requests
if (isLoading.value) return;
isLoading.value = true;
final response = await ApiService.getDocumentListApi(
entityTypeId: entityTypeId,
entityId: entityId,
filter: filter ?? "",
filter: filter ?? '',
searchString: searchString ?? searchQuery.value,
pageNumber: pageNumber.value,
pageSize: pageSize,
@ -147,19 +142,27 @@ class DocumentController extends GetxController {
} else {
hasMore.value = false;
}
errorMessage.value = '';
} else {
errorMessage.value = response?.message ?? "Failed to fetch documents";
errorMessage.value = response?.message ?? 'Failed to fetch documents';
if (documents.isEmpty) {
_showError('Failed to load documents');
}
}
} catch (e) {
errorMessage.value = "Error fetching documents: $e";
errorMessage.value = 'Error fetching documents: $e';
if (documents.isEmpty) {
_showError('Error loading documents');
}
debugPrint('❌ Error fetching documents: $e');
} finally {
isLoading.value = false;
}
}
// ------------------ Helpers -----------------------
// ==================== Helper Methods ====================
/// Clear selected filters
/// Clear all selected filters
void clearFilters() {
selectedUploadedBy.clear();
selectedCategory.clear();
@ -171,11 +174,40 @@ class DocumentController extends GetxController {
endDate.value = null;
}
/// Check if any filters are active (for red dot indicator)
/// Check if any filters are active
bool hasActiveFilters() {
return selectedUploadedBy.isNotEmpty ||
selectedCategory.isNotEmpty ||
selectedType.isNotEmpty ||
selectedTag.isNotEmpty;
selectedTag.isNotEmpty ||
startDate.value != null ||
endDate.value != null ||
isVerified.value != null;
}
/// Show error message
void _showError(String message) {
Get.snackbar(
'Error',
message,
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red.shade100,
colorText: Colors.red.shade900,
margin: const EdgeInsets.all(16),
borderRadius: 8,
duration: const Duration(seconds: 3),
);
}
/// Reset controller state
void reset() {
documents.clear();
clearFilters();
searchController.clear();
searchQuery.value = '';
pageNumber.value = 1;
hasMore.value = true;
showInactive.value = false;
errorMessage.value = '';
}
}

View File

@ -17,6 +17,7 @@ import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_model.dart';
import 'package:marco/helpers/widgets/time_stamp_image_helper.dart';
class AddExpenseController extends GetxController {
// --- Text Controllers ---
@ -65,6 +66,7 @@ class AddExpenseController extends GetxController {
final paymentModes = <PaymentModeModel>[].obs;
final allEmployees = <EmployeeModel>[].obs;
final employeeSearchResults = <EmployeeModel>[].obs;
final isProcessingAttachment = false.obs;
String? editingExpenseId;
@ -252,9 +254,22 @@ class AddExpenseController extends GetxController {
Future<void> pickFromCamera() async {
try {
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) attachments.add(File(pickedFile.path));
if (pickedFile != null) {
isProcessingAttachment.value = true; // start loading
File imageFile = File(pickedFile.path);
// Add timestamp to the captured image
File timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: imageFile,
);
attachments.add(timestampedFile);
attachments.refresh(); // refresh UI
}
} catch (e) {
_errorSnackbar("Camera error: $e");
} finally {
isProcessingAttachment.value = false; // stop loading
}
}

View File

@ -4,7 +4,6 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/wave_background.dart';
import 'package:marco/helpers/theme/admin_theme.dart';
import 'package:marco/helpers/theme/theme_customizer.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
class ThemeOption {
final String label;
@ -106,20 +105,6 @@ class _ThemeEditorWidgetState extends State<ThemeEditorWidget> {
],
),
const SizedBox(height: 12),
InkWell(
onTap: () {
ThemeCustomizer.setTheme(
ThemeCustomizer.instance.theme == ThemeMode.dark
? ThemeMode.light
: ThemeMode.dark);
},
child: Icon(
ThemeCustomizer.instance.theme == ThemeMode.dark
? LucideIcons.sun
: LucideIcons.moon,
size: 18,
),
),
// Theme cards wrapped in reactive Obx widget
Center(
child: Obx(

View File

@ -0,0 +1,97 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/services/app_logger.dart';
class TimestampImageHelper {
/// Adds a timestamp to an image file and returns a new File
static Future<File> addTimestamp({
required File imageFile,
Color textColor = Colors.white,
double fontSize = 60,
Color backgroundColor = Colors.black54,
double padding = 40,
double bottomPadding = 60,
}) async {
try {
// Read the image file
final bytes = await imageFile.readAsBytes();
final originalImage = await decodeImageFromList(bytes);
// Create a canvas
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
// Draw original image
final paint = Paint();
canvas.drawImage(originalImage, Offset.zero, paint);
// Timestamp text
final now = DateTime.now();
final timestamp = DateFormat('dd MMM yyyy hh:mm:ss a').format(now);
final textStyle = ui.TextStyle(
color: textColor,
fontSize: fontSize,
fontWeight: FontWeight.bold,
shadows: [
const ui.Shadow(
color: Colors.black,
offset: Offset(3, 3),
blurRadius: 6,
),
],
);
final paragraphStyle = ui.ParagraphStyle(textAlign: TextAlign.left);
final paragraphBuilder = ui.ParagraphBuilder(paragraphStyle)
..pushStyle(textStyle)
..addText(timestamp);
final paragraph = paragraphBuilder.build();
paragraph.layout(const ui.ParagraphConstraints(width: double.infinity));
final textWidth = paragraph.maxIntrinsicWidth;
final yPosition = originalImage.height - paragraph.height - bottomPadding;
final xPosition = (originalImage.width - textWidth) / 2;
// Draw background
final backgroundPaint = Paint()
..color = backgroundColor
..style = PaintingStyle.fill;
final backgroundRect = Rect.fromLTWH(
xPosition - padding,
yPosition - 15,
textWidth + padding * 2,
paragraph.height + 30,
);
canvas.drawRRect(
RRect.fromRectAndRadius(backgroundRect, const Radius.circular(8)),
backgroundPaint,
);
// Draw timestamp text
canvas.drawParagraph(paragraph, Offset(xPosition, yPosition));
// Convert canvas to image
final picture = recorder.endRecording();
final img = await picture.toImage(originalImage.width, originalImage.height);
final byteData = await img.toByteData(format: ui.ImageByteFormat.png);
final buffer = byteData!.buffer.asUint8List();
// Save to temporary file
final tempDir = await Directory.systemTemp.createTemp();
final timestampedFile = File('${tempDir.path}/timestamped_${DateTime.now().millisecondsSinceEpoch}.png');
await timestampedFile.writeAsBytes(buffer);
return timestampedFile;
} catch (e, stacktrace) {
logSafe("Error adding timestamp to image", level: LogLevel.error, error: e, stackTrace: stacktrace);
return imageFile; // fallback
}
}
}

View File

@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/expense/add_expense_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.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';
@ -11,7 +12,6 @@ import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart';
import 'package:marco/view/project/create_project_bottom_sheet.dart';
/// Show bottom sheet wrapper
Future<T?> showAddExpenseBottomSheet<T>({
@ -19,7 +19,10 @@ Future<T?> showAddExpenseBottomSheet<T>({
Map<String, dynamic>? existingExpense,
}) {
return Get.bottomSheet<T>(
_AddExpenseBottomSheet(isEdit: isEdit, existingExpense: existingExpense),
_AddExpenseBottomSheet(
isEdit: isEdit,
existingExpense: existingExpense,
),
isScrollControlled: true,
);
}
@ -38,7 +41,8 @@ class _AddExpenseBottomSheet extends StatefulWidget {
State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState();
}
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
with UIMixin {
final AddExpenseController controller = Get.put(AddExpenseController());
final _formKey = GlobalKey<FormState>();
@ -46,6 +50,95 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
final GlobalKey _expenseTypeDropdownKey = GlobalKey();
final GlobalKey _paymentModeDropdownKey = GlobalKey();
/// Show employee list
Future<void> _showEmployeeList() async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => ReusableEmployeeSelectorBottomSheet(
searchController: controller.employeeSearchController,
searchResults: controller.employeeSearchResults,
isSearching: controller.isSearchingEmployees,
onSearch: controller.searchEmployees,
onSelect: (emp) => controller.selectedPaidBy.value = emp,
),
);
controller.employeeSearchController.clear();
controller.employeeSearchResults.clear();
}
/// Generic option list
Future<void> _showOptionList<T>(
List<T> options,
String Function(T) getLabel,
ValueChanged<T> onSelected,
GlobalKey triggerKey,
) async {
final RenderBox button =
triggerKey.currentContext!.findRenderObject() as RenderBox;
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero, ancestor: overlay);
final selected = await showMenu<T>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy + button.size.height,
overlay.size.width - position.dx - button.size.width,
0,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
items: options
.map((opt) => PopupMenuItem<T>(
value: opt,
child: Text(getLabel(opt)),
))
.toList(),
);
if (selected != null) onSelected(selected);
}
/// Validate required selections
bool _validateSelections() {
if (controller.selectedProject.value.isEmpty) {
_showError("Please select a project");
return false;
}
if (controller.selectedExpenseType.value == null) {
_showError("Please select an expense type");
return false;
}
if (controller.selectedPaymentMode.value == null) {
_showError("Please select a payment mode");
return false;
}
if (controller.selectedPaidBy.value == null) {
_showError("Please select a person who paid");
return false;
}
if (controller.attachments.isEmpty &&
controller.existingAttachments.isEmpty) {
_showError("Please attach at least one document");
return false;
}
return true;
}
void _showError(String msg) {
showAppSnackbar(
title: "Error",
message: msg,
type: SnackbarType.error,
);
}
@override
Widget build(BuildContext context) {
return Obx(
@ -55,44 +148,140 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
title: widget.isEdit ? "Edit Expense" : "Add Expense",
isSubmitting: controller.isSubmitting.value,
onCancel: Get.back,
onSubmit: _handleSubmit,
onSubmit: () {
if (_formKey.currentState!.validate() && _validateSelections()) {
controller.submitOrUpdateExpense();
} else {
_showError("Please fill all required fields correctly");
}
},
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCreateProjectButton(),
_buildProjectDropdown(),
_buildDropdownField<String>(
icon: Icons.work_outline,
title: "Project",
requiredField: true,
value: controller.selectedProject.value.isEmpty
? "Select Project"
: controller.selectedProject.value,
onTap: () => _showOptionList<String>(
controller.globalProjects.toList(),
(p) => p,
(val) => controller.selectedProject.value = val,
_projectDropdownKey,
),
dropdownKey: _projectDropdownKey,
),
_gap(),
_buildExpenseTypeDropdown(),
_buildDropdownField<ExpenseTypeModel>(
icon: Icons.category_outlined,
title: "Expense Type",
requiredField: true,
value: controller.selectedExpenseType.value?.name ??
"Select Expense Type",
onTap: () => _showOptionList<ExpenseTypeModel>(
controller.expenseTypes.toList(),
(e) => e.name,
(val) => controller.selectedExpenseType.value = val,
_expenseTypeDropdownKey,
),
dropdownKey: _expenseTypeDropdownKey,
),
// Persons if required
if (controller.selectedExpenseType.value?.noOfPersonsRequired ==
true) ...[
_gap(),
_buildNumberField(
_buildTextFieldSection(
icon: Icons.people_outline,
title: "No. of Persons",
controller: controller.noOfPersonsController,
hint: "Enter No. of Persons",
keyboardType: TextInputType.number,
validator: Validators.requiredField,
),
],
_gap(),
_buildPaymentModeDropdown(),
_buildTextFieldSection(
icon: Icons.confirmation_number_outlined,
title: "GST No.",
controller: controller.gstController,
hint: "Enter GST No.",
),
_gap(),
_buildDropdownField<PaymentModeModel>(
icon: Icons.payment,
title: "Payment Mode",
requiredField: true,
value: controller.selectedPaymentMode.value?.name ??
"Select Payment Mode",
onTap: () => _showOptionList<PaymentModeModel>(
controller.paymentModes.toList(),
(p) => p.name,
(val) => controller.selectedPaymentMode.value = val,
_paymentModeDropdownKey,
),
dropdownKey: _paymentModeDropdownKey,
),
_gap(),
_buildPaidBySection(),
_gap(),
_buildAmountField(),
_buildTextFieldSection(
icon: Icons.currency_rupee,
title: "Amount",
controller: controller.amountController,
hint: "Enter Amount",
keyboardType: TextInputType.number,
validator: (v) => Validators.isNumeric(v ?? "")
? null
: "Enter valid amount",
),
_gap(),
_buildSupplierField(),
_buildTextFieldSection(
icon: Icons.store_mall_directory_outlined,
title: "Supplier Name/Transporter Name/Other",
controller: controller.supplierController,
hint: "Enter Supplier Name/Transporter Name or Other",
validator: Validators.nameValidator,
),
_gap(),
_buildTextFieldSection(
icon: Icons.confirmation_number_outlined,
title: "Transaction ID",
controller: controller.transactionIdController,
hint: "Enter Transaction ID",
validator: (v) => (v != null && v.isNotEmpty)
? Validators.transactionIdValidator(v)
: null,
),
_gap(),
_buildTransactionDateField(),
_gap(),
_buildTransactionIdField(),
_gap(),
_buildLocationField(),
_gap(),
_buildAttachmentsSection(),
_gap(),
_buildDescriptionField(),
_buildTextFieldSection(
icon: Icons.description_outlined,
title: "Description",
controller: controller.descriptionController,
hint: "Enter Description",
maxLines: 3,
validator: Validators.requiredField,
),
],
),
),
@ -101,137 +290,101 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
);
}
/// 🟦 UI SECTION BUILDERS
Widget _gap([double h = 16]) => MySpacing.height(h);
Widget _buildCreateProjectButton() {
return Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () async {
await Get.bottomSheet(const CreateProjectBottomSheet(),
isScrollControlled: true);
await controller.fetchGlobalProjects();
},
icon: const Icon(Icons.add, color: Colors.blue),
label: const Text(
"Create Project",
style: TextStyle(color: Colors.blue, fontWeight: FontWeight.w600),
Widget _buildDropdownField<T>({
required IconData icon,
required String title,
required bool requiredField,
required String value,
required VoidCallback onTap,
required GlobalKey dropdownKey,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(icon: icon, title: title, requiredField: requiredField),
MySpacing.height(6),
DropdownTile(key: dropdownKey, title: value, onTap: onTap),
],
);
}
Widget _buildTextFieldSection({
required IconData icon,
required String title,
required TextEditingController controller,
String? hint,
TextInputType? keyboardType,
FormFieldValidator<String>? validator,
int maxLines = 1,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(
icon: icon, title: title, requiredField: validator != null),
MySpacing.height(6),
CustomTextField(
controller: controller,
hint: hint ?? "",
keyboardType: keyboardType ?? TextInputType.text,
validator: validator,
maxLines: maxLines,
),
),
);
}
Widget _buildProjectDropdown() {
return _buildDropdownField<String>(
icon: Icons.work_outline,
title: "Project",
requiredField: true,
value: controller.selectedProject.value.isEmpty
? "Select Project"
: controller.selectedProject.value,
onTap: _showProjectSelector,
dropdownKey: _projectDropdownKey,
);
}
Widget _buildExpenseTypeDropdown() {
return _buildDropdownField<ExpenseTypeModel>(
icon: Icons.category_outlined,
title: "Expense Type",
requiredField: true,
value:
controller.selectedExpenseType.value?.name ?? "Select Expense Type",
onTap: () => _showOptionList(
controller.expenseTypes.toList(),
(e) => e.name,
(val) => controller.selectedExpenseType.value = val,
_expenseTypeDropdownKey,
),
dropdownKey: _expenseTypeDropdownKey,
);
}
Widget _buildPaymentModeDropdown() {
return _buildDropdownField<PaymentModeModel>(
icon: Icons.payment,
title: "Payment Mode",
requiredField: true,
value:
controller.selectedPaymentMode.value?.name ?? "Select Payment Mode",
onTap: () => _showOptionList(
controller.paymentModes.toList(),
(p) => p.name,
(val) => controller.selectedPaymentMode.value = val,
_paymentModeDropdownKey,
),
dropdownKey: _paymentModeDropdownKey,
],
);
}
Widget _buildPaidBySection() {
return _buildTileSelector(
icon: Icons.person_outline,
title: "Paid By",
required: true,
displayText: controller.selectedPaidBy.value == null
? "Select Paid By"
: '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}',
onTap: _showEmployeeList,
);
}
Widget _buildAmountField() => _buildNumberField(
icon: Icons.currency_rupee,
title: "Amount",
controller: controller.amountController,
hint: "Enter Amount",
validator: (v) =>
Validators.isNumeric(v ?? "") ? null : "Enter valid amount",
);
Widget _buildSupplierField() => _buildTextField(
icon: Icons.store_mall_directory_outlined,
title: "Supplier Name/Transporter Name/Other",
controller: controller.supplierController,
hint: "Enter Supplier Name/Transporter Name or Other",
validator: Validators.nameValidator,
);
Widget _buildTransactionIdField() {
final paymentMode =
controller.selectedPaymentMode.value?.name.toLowerCase() ?? '';
final isRequired = paymentMode.isNotEmpty &&
paymentMode != 'cash' &&
paymentMode != 'cheque';
return _buildTextField(
icon: Icons.confirmation_number_outlined,
title: "Transaction ID",
controller: controller.transactionIdController,
hint: "Enter Transaction ID",
validator: (v) {
if (isRequired) {
if (v == null || v.isEmpty)
return "Transaction ID is required for this payment mode";
return Validators.transactionIdValidator(v);
}
return null;
},
requiredField: isRequired,
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const 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),
],
),
),
),
],
);
}
Widget _buildTransactionDateField() {
return Obx(() => _buildTileSelector(
icon: Icons.calendar_today,
title: "Transaction Date",
required: true,
displayText: controller.selectedTransactionDate.value == null
? "Select Transaction Date"
: DateFormat('dd MMM yyyy')
.format(controller.selectedTransactionDate.value!),
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(
icon: Icons.calendar_today,
title: "Transaction Date",
requiredField: true),
MySpacing.height(6),
GestureDetector(
onTap: () => controller.pickTransactionDate(context),
));
child: AbsorbPointer(
child: CustomTextField(
controller: controller.transactionDateController,
hint: "Select Transaction Date",
validator: Validators.requiredField,
),
),
),
],
);
}
Widget _buildLocationField() {
@ -278,281 +431,62 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
title: "Attachments",
requiredField: true,
),
MySpacing.height(6),
AttachmentsSection(
attachments: controller.attachments,
existingAttachments: controller.existingAttachments,
onRemoveNew: controller.removeAttachment,
onRemoveExisting: _confirmRemoveAttachment,
onAdd: controller.pickAttachments,
),
],
);
}
Widget _buildDescriptionField() => _buildTextField(
icon: Icons.description_outlined,
title: "Description",
controller: controller.descriptionController,
hint: "Enter Description",
maxLines: 3,
validator: Validators.requiredField,
);
/// 🟩 COMMON HELPERS
Widget _gap([double h = 16]) => MySpacing.height(h);
Widget _buildDropdownField<T>({
required IconData icon,
required String title,
required bool requiredField,
required String value,
required VoidCallback onTap,
required GlobalKey dropdownKey,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(icon: icon, title: title, requiredField: requiredField),
MySpacing.height(6),
DropdownTile(key: dropdownKey, title: value, onTap: onTap),
],
);
}
Widget _buildTextField({
required IconData icon,
required String title,
required TextEditingController controller,
String? hint,
FormFieldValidator<String>? validator,
bool requiredField = true,
int maxLines = 1,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(icon: icon, title: title, requiredField: requiredField),
MySpacing.height(6),
CustomTextField(
controller: controller,
hint: hint ?? "",
validator: validator,
maxLines: maxLines,
),
],
);
}
Widget _buildNumberField({
required IconData icon,
required String title,
required TextEditingController controller,
String? hint,
FormFieldValidator<String>? validator,
}) {
return _buildTextField(
icon: icon,
title: title,
controller: controller,
hint: hint,
validator: validator,
);
}
Widget _buildTileSelector({
required IconData icon,
required String title,
required String displayText,
required VoidCallback onTap,
bool required = false,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(icon: icon, title: title, requiredField: required),
MySpacing.height(6),
GestureDetector(
onTap: onTap,
child: TileContainer(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(displayText, style: const TextStyle(fontSize: 14)),
const Icon(Icons.arrow_drop_down, size: 22),
],
),
),
),
],
);
}
/// 🧰 LOGIC HELPERS
Future<void> _showProjectSelector() async {
final sortedProjects = controller.globalProjects.toList()
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
const specialOption = 'Create New Project';
final displayList = [...sortedProjects, specialOption];
final selected = await showMenu<String>(
context: context,
position: _getPopupMenuPosition(_projectDropdownKey),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
items: displayList.map((opt) {
final isSpecial = opt == specialOption;
return PopupMenuItem<String>(
value: opt,
child: isSpecial
? Row(
children: const [
Icon(Icons.add, color: Colors.blue),
SizedBox(width: 8),
Text(
specialOption,
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.blue,
),
),
],
)
: Text(
opt,
style: const TextStyle(
fontWeight: FontWeight.normal,
color: Colors.black,
MySpacing.height(10),
Obx(() {
if (controller.isProcessingAttachment.value) {
return Center(
child: Column(
children: [
CircularProgressIndicator(
color: contentTheme.primary,
),
),
);
}).toList(),
);
if (selected == null) return;
if (selected == specialOption) {
controller.selectedProject.value = specialOption;
await Get.bottomSheet(const CreateProjectBottomSheet(),
isScrollControlled: true);
await controller.fetchGlobalProjects();
controller.selectedProject.value = "";
} else {
controller.selectedProject.value = selected;
}
}
Future<void> _showEmployeeList() async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => ReusableEmployeeSelectorBottomSheet(
searchController: controller.employeeSearchController,
searchResults: controller.employeeSearchResults,
isSearching: controller.isSearchingEmployees,
onSearch: controller.searchEmployees,
onSelect: (emp) => controller.selectedPaidBy.value = emp,
),
);
controller.employeeSearchController.clear();
controller.employeeSearchResults.clear();
}
Future<void> _confirmRemoveAttachment(item) async {
await showDialog(
context: context,
barrierDismissible: false,
builder: (_) => ConfirmDialog(
title: "Remove Attachment",
message: "Are you sure you want to remove this attachment?",
confirmText: "Remove",
icon: Icons.delete,
confirmColor: Colors.redAccent,
onConfirm: () async {
final index = controller.existingAttachments.indexOf(item);
if (index != -1) {
controller.existingAttachments[index]['isActive'] = false;
controller.existingAttachments.refresh();
const SizedBox(height: 8),
Text(
"Processing image, please wait...",
style: TextStyle(
fontSize: 14,
color: contentTheme.primary,
),
),
],
),
);
}
showAppSnackbar(
title: 'Removed',
message: 'Attachment has been removed.',
type: SnackbarType.success,
return AttachmentsSection(
attachments: controller.attachments,
existingAttachments: controller.existingAttachments,
onRemoveNew: controller.removeAttachment,
onRemoveExisting: (item) async {
await showDialog(
context: context,
barrierDismissible: false,
builder: (_) => ConfirmDialog(
title: "Remove Attachment",
message: "Are you sure you want to remove this attachment?",
confirmText: "Remove",
icon: Icons.delete,
confirmColor: Colors.redAccent,
onConfirm: () async {
final index = controller.existingAttachments.indexOf(item);
if (index != -1) {
controller.existingAttachments[index]['isActive'] = false;
controller.existingAttachments.refresh();
}
showAppSnackbar(
title: 'Removed',
message: 'Attachment has been removed.',
type: SnackbarType.success,
);
Navigator.pop(context);
},
),
);
},
onAdd: controller.pickAttachments,
);
},
),
}),
],
);
}
Future<void> _showOptionList<T>(
List<T> options,
String Function(T) getLabel,
ValueChanged<T> onSelected,
GlobalKey triggerKey,
) async {
final selected = await showMenu<T>(
context: context,
position: _getPopupMenuPosition(triggerKey),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
items: options
.map((opt) => PopupMenuItem<T>(
value: opt,
child: Text(getLabel(opt)),
))
.toList(),
);
if (selected != null) onSelected(selected);
}
RelativeRect _getPopupMenuPosition(GlobalKey key) {
final RenderBox button =
key.currentContext!.findRenderObject() as RenderBox;
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero, ancestor: overlay);
return RelativeRect.fromLTRB(
position.dx,
position.dy + button.size.height,
overlay.size.width - position.dx - button.size.width,
0,
);
}
bool _validateSelections() {
if (controller.selectedProject.value.isEmpty) {
return _error("Please select a project");
}
if (controller.selectedExpenseType.value == null) {
return _error("Please select an expense type");
}
if (controller.selectedPaymentMode.value == null) {
return _error("Please select a payment mode");
}
if (controller.selectedPaidBy.value == null) {
return _error("Please select a person who paid");
}
if (controller.attachments.isEmpty &&
controller.existingAttachments.isEmpty) {
return _error("Please attach at least one document");
}
return true;
}
bool _error(String msg) {
showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error);
return false;
}
void _handleSubmit() {
if (_formKey.currentState!.validate() && _validateSelections()) {
controller.submitOrUpdateExpense();
} else {
_error("Please fill all required fields correctly");
}
}
}

File diff suppressed because it is too large Load Diff