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:
parent
62eb7b1d97
commit
bc9fc4d6f1
@ -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;
|
||||
|
||||
@ -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 = '';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
97
lib/helpers/widgets/time_stamp_image_helper.dart
Normal file
97
lib/helpers/widgets/time_stamp_image_helper.dart
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user