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/app_logger.dart';
|
||||||
import 'package:marco/helpers/services/api_service.dart';
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
import 'package:marco/helpers/widgets/my_image_compressor.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/attendance/attendance_model.dart';
|
||||||
import 'package:marco/model/project_model.dart';
|
import 'package:marco/model/project_model.dart';
|
||||||
@ -174,8 +175,12 @@ String selectedTab = 'todaysAttendance';
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔹 Add timestamp to the image
|
||||||
|
final timestampedFile = await TimestampImageHelper.addTimestamp(
|
||||||
|
imageFile: File(image.path));
|
||||||
|
|
||||||
final compressedBytes =
|
final compressedBytes =
|
||||||
await compressImageToUnder100KB(File(image.path));
|
await compressImageToUnder100KB(timestampedFile);
|
||||||
if (compressedBytes == null) {
|
if (compressedBytes == null) {
|
||||||
logSafe("Image compression failed.", level: LogLevel.error);
|
logSafe("Image compression failed.", level: LogLevel.error);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -5,54 +5,63 @@ import 'package:marco/model/document/document_filter_model.dart';
|
|||||||
import 'package:marco/model/document/documents_list_model.dart';
|
import 'package:marco/model/document/documents_list_model.dart';
|
||||||
|
|
||||||
class DocumentController extends GetxController {
|
class DocumentController extends GetxController {
|
||||||
// ------------------ Observables ---------------------
|
// ==================== Observables ====================
|
||||||
var isLoading = false.obs;
|
final isLoading = false.obs;
|
||||||
var documents = <DocumentItem>[].obs;
|
final documents = <DocumentItem>[].obs;
|
||||||
var filters = Rxn<DocumentFiltersData>();
|
final filters = Rxn<DocumentFiltersData>();
|
||||||
|
|
||||||
// ✅ Selected filters (multi-select support)
|
// Selected filters (multi-select)
|
||||||
var selectedUploadedBy = <String>[].obs;
|
final selectedUploadedBy = <String>[].obs;
|
||||||
var selectedCategory = <String>[].obs;
|
final selectedCategory = <String>[].obs;
|
||||||
var selectedType = <String>[].obs;
|
final selectedType = <String>[].obs;
|
||||||
var selectedTag = <String>[].obs;
|
final selectedTag = <String>[].obs;
|
||||||
|
|
||||||
// Pagination state
|
// Pagination
|
||||||
var pageNumber = 1.obs;
|
final pageNumber = 1.obs;
|
||||||
final int pageSize = 20;
|
final pageSize = 20;
|
||||||
var hasMore = true.obs;
|
final hasMore = true.obs;
|
||||||
|
|
||||||
// Error message
|
// Error handling
|
||||||
var errorMessage = "".obs;
|
final errorMessage = ''.obs;
|
||||||
|
|
||||||
// NEW: show inactive toggle
|
// Preferences
|
||||||
var showInactive = false.obs;
|
final showInactive = false.obs;
|
||||||
|
|
||||||
// NEW: search
|
// Search
|
||||||
var searchQuery = ''.obs;
|
final searchQuery = ''.obs;
|
||||||
var searchController = TextEditingController();
|
final searchController = TextEditingController();
|
||||||
// New filter fields
|
|
||||||
var isUploadedAt = true.obs;
|
|
||||||
var isVerified = RxnBool();
|
|
||||||
var startDate = Rxn<String>();
|
|
||||||
var endDate = Rxn<String>();
|
|
||||||
|
|
||||||
// ------------------ 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 {
|
Future<void> fetchFilters(String entityTypeId) async {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true;
|
|
||||||
final response = await ApiService.getDocumentFilters(entityTypeId);
|
final response = await ApiService.getDocumentFilters(entityTypeId);
|
||||||
|
|
||||||
if (response != null && response.success) {
|
if (response != null && response.success) {
|
||||||
filters.value = response.data;
|
filters.value = response.data;
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = response?.message ?? "Failed to fetch filters";
|
errorMessage.value = response?.message ?? 'Failed to fetch filters';
|
||||||
|
_showError('Failed to load filters');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMessage.value = "Error fetching filters: $e";
|
errorMessage.value = 'Error fetching filters: $e';
|
||||||
} finally {
|
_showError('Error loading filters');
|
||||||
isLoading.value = false;
|
debugPrint('❌ Error fetching filters: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,11 +74,14 @@ class DocumentController extends GetxController {
|
|||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
final success =
|
|
||||||
await ApiService.deleteDocumentApi(id: id, isActive: isActive);
|
final success = await ApiService.deleteDocumentApi(
|
||||||
|
id: id,
|
||||||
|
isActive: isActive,
|
||||||
|
);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// 🔥 Always fetch fresh list after toggle
|
// Refresh list after state change
|
||||||
await fetchDocuments(
|
await fetchDocuments(
|
||||||
entityTypeId: entityTypeId,
|
entityTypeId: entityTypeId,
|
||||||
entityId: entityId,
|
entityId: entityId,
|
||||||
@ -77,41 +89,19 @@ class DocumentController extends GetxController {
|
|||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = "Failed to update document state";
|
errorMessage.value = 'Failed to update document state';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMessage.value = "Error updating document: $e";
|
errorMessage.value = 'Error updating document: $e';
|
||||||
|
debugPrint('❌ Error toggling document state: $e');
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Permanently delete a document (or deactivate depending on API)
|
/// Fetch documents for entity with pagination
|
||||||
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
|
|
||||||
Future<void> fetchDocuments({
|
Future<void> fetchDocuments({
|
||||||
required String entityTypeId,
|
required String entityTypeId,
|
||||||
required String entityId,
|
required String entityId,
|
||||||
@ -120,20 +110,25 @@ class DocumentController extends GetxController {
|
|||||||
bool reset = false,
|
bool reset = false,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
|
// Reset pagination if needed
|
||||||
if (reset) {
|
if (reset) {
|
||||||
pageNumber.value = 1;
|
pageNumber.value = 1;
|
||||||
documents.clear();
|
documents.clear();
|
||||||
hasMore.value = true;
|
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;
|
isLoading.value = true;
|
||||||
|
|
||||||
final response = await ApiService.getDocumentListApi(
|
final response = await ApiService.getDocumentListApi(
|
||||||
entityTypeId: entityTypeId,
|
entityTypeId: entityTypeId,
|
||||||
entityId: entityId,
|
entityId: entityId,
|
||||||
filter: filter ?? "",
|
filter: filter ?? '',
|
||||||
searchString: searchString ?? searchQuery.value,
|
searchString: searchString ?? searchQuery.value,
|
||||||
pageNumber: pageNumber.value,
|
pageNumber: pageNumber.value,
|
||||||
pageSize: pageSize,
|
pageSize: pageSize,
|
||||||
@ -147,19 +142,27 @@ class DocumentController extends GetxController {
|
|||||||
} else {
|
} else {
|
||||||
hasMore.value = false;
|
hasMore.value = false;
|
||||||
}
|
}
|
||||||
|
errorMessage.value = '';
|
||||||
} else {
|
} 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) {
|
} 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 {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------ Helpers -----------------------
|
// ==================== Helper Methods ====================
|
||||||
|
|
||||||
/// Clear selected filters
|
/// Clear all selected filters
|
||||||
void clearFilters() {
|
void clearFilters() {
|
||||||
selectedUploadedBy.clear();
|
selectedUploadedBy.clear();
|
||||||
selectedCategory.clear();
|
selectedCategory.clear();
|
||||||
@ -171,11 +174,40 @@ class DocumentController extends GetxController {
|
|||||||
endDate.value = null;
|
endDate.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if any filters are active (for red dot indicator)
|
/// Check if any filters are active
|
||||||
bool hasActiveFilters() {
|
bool hasActiveFilters() {
|
||||||
return selectedUploadedBy.isNotEmpty ||
|
return selectedUploadedBy.isNotEmpty ||
|
||||||
selectedCategory.isNotEmpty ||
|
selectedCategory.isNotEmpty ||
|
||||||
selectedType.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/employees/employee_model.dart';
|
||||||
import 'package:marco/model/expense/expense_type_model.dart';
|
import 'package:marco/model/expense/expense_type_model.dart';
|
||||||
import 'package:marco/model/expense/payment_types_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 {
|
class AddExpenseController extends GetxController {
|
||||||
// --- Text Controllers ---
|
// --- Text Controllers ---
|
||||||
@ -65,6 +66,7 @@ class AddExpenseController extends GetxController {
|
|||||||
final paymentModes = <PaymentModeModel>[].obs;
|
final paymentModes = <PaymentModeModel>[].obs;
|
||||||
final allEmployees = <EmployeeModel>[].obs;
|
final allEmployees = <EmployeeModel>[].obs;
|
||||||
final employeeSearchResults = <EmployeeModel>[].obs;
|
final employeeSearchResults = <EmployeeModel>[].obs;
|
||||||
|
final isProcessingAttachment = false.obs;
|
||||||
|
|
||||||
String? editingExpenseId;
|
String? editingExpenseId;
|
||||||
|
|
||||||
@ -252,9 +254,22 @@ class AddExpenseController extends GetxController {
|
|||||||
Future<void> pickFromCamera() async {
|
Future<void> pickFromCamera() async {
|
||||||
try {
|
try {
|
||||||
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
|
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) {
|
} catch (e) {
|
||||||
_errorSnackbar("Camera error: $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/widgets/wave_background.dart';
|
||||||
import 'package:marco/helpers/theme/admin_theme.dart';
|
import 'package:marco/helpers/theme/admin_theme.dart';
|
||||||
import 'package:marco/helpers/theme/theme_customizer.dart';
|
import 'package:marco/helpers/theme/theme_customizer.dart';
|
||||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
|
||||||
|
|
||||||
class ThemeOption {
|
class ThemeOption {
|
||||||
final String label;
|
final String label;
|
||||||
@ -106,20 +105,6 @@ class _ThemeEditorWidgetState extends State<ThemeEditorWidget> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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
|
// Theme cards wrapped in reactive Obx widget
|
||||||
Center(
|
Center(
|
||||||
child: Obx(
|
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:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:marco/controller/expense/add_expense_controller.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/expense_type_model.dart';
|
||||||
import 'package:marco/model/expense/payment_types_model.dart';
|
import 'package:marco/model/expense/payment_types_model.dart';
|
||||||
import 'package:marco/model/expense/employee_selector_bottom_sheet.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_snackbar.dart';
|
||||||
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
||||||
import 'package:marco/helpers/widgets/expense/expense_form_widgets.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
|
/// Show bottom sheet wrapper
|
||||||
Future<T?> showAddExpenseBottomSheet<T>({
|
Future<T?> showAddExpenseBottomSheet<T>({
|
||||||
@ -19,7 +19,10 @@ Future<T?> showAddExpenseBottomSheet<T>({
|
|||||||
Map<String, dynamic>? existingExpense,
|
Map<String, dynamic>? existingExpense,
|
||||||
}) {
|
}) {
|
||||||
return Get.bottomSheet<T>(
|
return Get.bottomSheet<T>(
|
||||||
_AddExpenseBottomSheet(isEdit: isEdit, existingExpense: existingExpense),
|
_AddExpenseBottomSheet(
|
||||||
|
isEdit: isEdit,
|
||||||
|
existingExpense: existingExpense,
|
||||||
|
),
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -38,7 +41,8 @@ class _AddExpenseBottomSheet extends StatefulWidget {
|
|||||||
State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState();
|
State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
|
||||||
|
with UIMixin {
|
||||||
final AddExpenseController controller = Get.put(AddExpenseController());
|
final AddExpenseController controller = Get.put(AddExpenseController());
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
@ -46,6 +50,95 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
|||||||
final GlobalKey _expenseTypeDropdownKey = GlobalKey();
|
final GlobalKey _expenseTypeDropdownKey = GlobalKey();
|
||||||
final GlobalKey _paymentModeDropdownKey = 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Obx(
|
return Obx(
|
||||||
@ -55,183 +148,243 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
|||||||
title: widget.isEdit ? "Edit Expense" : "Add Expense",
|
title: widget.isEdit ? "Edit Expense" : "Add Expense",
|
||||||
isSubmitting: controller.isSubmitting.value,
|
isSubmitting: controller.isSubmitting.value,
|
||||||
onCancel: Get.back,
|
onCancel: Get.back,
|
||||||
onSubmit: _handleSubmit,
|
onSubmit: () {
|
||||||
|
if (_formKey.currentState!.validate() && _validateSelections()) {
|
||||||
|
controller.submitOrUpdateExpense();
|
||||||
|
} else {
|
||||||
|
_showError("Please fill all required fields correctly");
|
||||||
|
}
|
||||||
|
},
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildCreateProjectButton(),
|
_buildDropdownField<String>(
|
||||||
_buildProjectDropdown(),
|
|
||||||
_gap(),
|
|
||||||
_buildExpenseTypeDropdown(),
|
|
||||||
if (controller.selectedExpenseType.value?.noOfPersonsRequired ==
|
|
||||||
true) ...[
|
|
||||||
_gap(),
|
|
||||||
_buildNumberField(
|
|
||||||
icon: Icons.people_outline,
|
|
||||||
title: "No. of Persons",
|
|
||||||
controller: controller.noOfPersonsController,
|
|
||||||
hint: "Enter No. of Persons",
|
|
||||||
validator: Validators.requiredField,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
_gap(),
|
|
||||||
_buildPaymentModeDropdown(),
|
|
||||||
_gap(),
|
|
||||||
_buildPaidBySection(),
|
|
||||||
_gap(),
|
|
||||||
_buildAmountField(),
|
|
||||||
_gap(),
|
|
||||||
_buildSupplierField(),
|
|
||||||
_gap(),
|
|
||||||
_buildTransactionDateField(),
|
|
||||||
_gap(),
|
|
||||||
_buildTransactionIdField(),
|
|
||||||
_gap(),
|
|
||||||
_buildLocationField(),
|
|
||||||
_gap(),
|
|
||||||
_buildAttachmentsSection(),
|
|
||||||
_gap(),
|
|
||||||
_buildDescriptionField(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 🟦 UI SECTION BUILDERS
|
|
||||||
|
|
||||||
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 _buildProjectDropdown() {
|
|
||||||
return _buildDropdownField<String>(
|
|
||||||
icon: Icons.work_outline,
|
icon: Icons.work_outline,
|
||||||
title: "Project",
|
title: "Project",
|
||||||
requiredField: true,
|
requiredField: true,
|
||||||
value: controller.selectedProject.value.isEmpty
|
value: controller.selectedProject.value.isEmpty
|
||||||
? "Select Project"
|
? "Select Project"
|
||||||
: controller.selectedProject.value,
|
: controller.selectedProject.value,
|
||||||
onTap: _showProjectSelector,
|
onTap: () => _showOptionList<String>(
|
||||||
|
controller.globalProjects.toList(),
|
||||||
|
(p) => p,
|
||||||
|
(val) => controller.selectedProject.value = val,
|
||||||
|
_projectDropdownKey,
|
||||||
|
),
|
||||||
dropdownKey: _projectDropdownKey,
|
dropdownKey: _projectDropdownKey,
|
||||||
);
|
),
|
||||||
}
|
_gap(),
|
||||||
|
|
||||||
Widget _buildExpenseTypeDropdown() {
|
_buildDropdownField<ExpenseTypeModel>(
|
||||||
return _buildDropdownField<ExpenseTypeModel>(
|
|
||||||
icon: Icons.category_outlined,
|
icon: Icons.category_outlined,
|
||||||
title: "Expense Type",
|
title: "Expense Type",
|
||||||
requiredField: true,
|
requiredField: true,
|
||||||
value:
|
value: controller.selectedExpenseType.value?.name ??
|
||||||
controller.selectedExpenseType.value?.name ?? "Select Expense Type",
|
"Select Expense Type",
|
||||||
onTap: () => _showOptionList(
|
onTap: () => _showOptionList<ExpenseTypeModel>(
|
||||||
controller.expenseTypes.toList(),
|
controller.expenseTypes.toList(),
|
||||||
(e) => e.name,
|
(e) => e.name,
|
||||||
(val) => controller.selectedExpenseType.value = val,
|
(val) => controller.selectedExpenseType.value = val,
|
||||||
_expenseTypeDropdownKey,
|
_expenseTypeDropdownKey,
|
||||||
),
|
),
|
||||||
dropdownKey: _expenseTypeDropdownKey,
|
dropdownKey: _expenseTypeDropdownKey,
|
||||||
);
|
),
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPaymentModeDropdown() {
|
// Persons if required
|
||||||
return _buildDropdownField<PaymentModeModel>(
|
if (controller.selectedExpenseType.value?.noOfPersonsRequired ==
|
||||||
|
true) ...[
|
||||||
|
_gap(),
|
||||||
|
_buildTextFieldSection(
|
||||||
|
icon: Icons.people_outline,
|
||||||
|
title: "No. of Persons",
|
||||||
|
controller: controller.noOfPersonsController,
|
||||||
|
hint: "Enter No. of Persons",
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
validator: Validators.requiredField,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
_gap(),
|
||||||
|
|
||||||
|
_buildTextFieldSection(
|
||||||
|
icon: Icons.confirmation_number_outlined,
|
||||||
|
title: "GST No.",
|
||||||
|
controller: controller.gstController,
|
||||||
|
hint: "Enter GST No.",
|
||||||
|
),
|
||||||
|
_gap(),
|
||||||
|
|
||||||
|
_buildDropdownField<PaymentModeModel>(
|
||||||
icon: Icons.payment,
|
icon: Icons.payment,
|
||||||
title: "Payment Mode",
|
title: "Payment Mode",
|
||||||
requiredField: true,
|
requiredField: true,
|
||||||
value:
|
value: controller.selectedPaymentMode.value?.name ??
|
||||||
controller.selectedPaymentMode.value?.name ?? "Select Payment Mode",
|
"Select Payment Mode",
|
||||||
onTap: () => _showOptionList(
|
onTap: () => _showOptionList<PaymentModeModel>(
|
||||||
controller.paymentModes.toList(),
|
controller.paymentModes.toList(),
|
||||||
(p) => p.name,
|
(p) => p.name,
|
||||||
(val) => controller.selectedPaymentMode.value = val,
|
(val) => controller.selectedPaymentMode.value = val,
|
||||||
_paymentModeDropdownKey,
|
_paymentModeDropdownKey,
|
||||||
),
|
),
|
||||||
dropdownKey: _paymentModeDropdownKey,
|
dropdownKey: _paymentModeDropdownKey,
|
||||||
);
|
),
|
||||||
}
|
_gap(),
|
||||||
|
|
||||||
Widget _buildPaidBySection() {
|
_buildPaidBySection(),
|
||||||
return _buildTileSelector(
|
_gap(),
|
||||||
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(
|
_buildTextFieldSection(
|
||||||
icon: Icons.currency_rupee,
|
icon: Icons.currency_rupee,
|
||||||
title: "Amount",
|
title: "Amount",
|
||||||
controller: controller.amountController,
|
controller: controller.amountController,
|
||||||
hint: "Enter Amount",
|
hint: "Enter Amount",
|
||||||
validator: (v) =>
|
keyboardType: TextInputType.number,
|
||||||
Validators.isNumeric(v ?? "") ? null : "Enter valid amount",
|
validator: (v) => Validators.isNumeric(v ?? "")
|
||||||
);
|
? null
|
||||||
|
: "Enter valid amount",
|
||||||
|
),
|
||||||
|
_gap(),
|
||||||
|
|
||||||
Widget _buildSupplierField() => _buildTextField(
|
_buildTextFieldSection(
|
||||||
icon: Icons.store_mall_directory_outlined,
|
icon: Icons.store_mall_directory_outlined,
|
||||||
title: "Supplier Name/Transporter Name/Other",
|
title: "Supplier Name/Transporter Name/Other",
|
||||||
controller: controller.supplierController,
|
controller: controller.supplierController,
|
||||||
hint: "Enter Supplier Name/Transporter Name or Other",
|
hint: "Enter Supplier Name/Transporter Name or Other",
|
||||||
validator: Validators.nameValidator,
|
validator: Validators.nameValidator,
|
||||||
);
|
),
|
||||||
|
_gap(),
|
||||||
|
|
||||||
Widget _buildTransactionIdField() {
|
_buildTextFieldSection(
|
||||||
final paymentMode =
|
|
||||||
controller.selectedPaymentMode.value?.name.toLowerCase() ?? '';
|
|
||||||
final isRequired = paymentMode.isNotEmpty &&
|
|
||||||
paymentMode != 'cash' &&
|
|
||||||
paymentMode != 'cheque';
|
|
||||||
|
|
||||||
return _buildTextField(
|
|
||||||
icon: Icons.confirmation_number_outlined,
|
icon: Icons.confirmation_number_outlined,
|
||||||
title: "Transaction ID",
|
title: "Transaction ID",
|
||||||
controller: controller.transactionIdController,
|
controller: controller.transactionIdController,
|
||||||
hint: "Enter Transaction ID",
|
hint: "Enter Transaction ID",
|
||||||
validator: (v) {
|
validator: (v) => (v != null && v.isNotEmpty)
|
||||||
if (isRequired) {
|
? Validators.transactionIdValidator(v)
|
||||||
if (v == null || v.isEmpty)
|
: null,
|
||||||
return "Transaction ID is required for this payment mode";
|
),
|
||||||
return Validators.transactionIdValidator(v);
|
_gap(),
|
||||||
|
|
||||||
|
_buildTransactionDateField(),
|
||||||
|
_gap(),
|
||||||
|
|
||||||
|
_buildLocationField(),
|
||||||
|
_gap(),
|
||||||
|
|
||||||
|
_buildAttachmentsSection(),
|
||||||
|
_gap(),
|
||||||
|
|
||||||
|
_buildTextFieldSection(
|
||||||
|
icon: Icons.description_outlined,
|
||||||
|
title: "Description",
|
||||||
|
controller: controller.descriptionController,
|
||||||
|
hint: "Enter Description",
|
||||||
|
maxLines: 3,
|
||||||
|
validator: Validators.requiredField,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
},
|
Widget _gap([double h = 16]) => MySpacing.height(h);
|
||||||
requiredField: isRequired,
|
|
||||||
|
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 _buildPaidBySection() {
|
||||||
|
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() {
|
Widget _buildTransactionDateField() {
|
||||||
return Obx(() => _buildTileSelector(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SectionTitle(
|
||||||
icon: Icons.calendar_today,
|
icon: Icons.calendar_today,
|
||||||
title: "Transaction Date",
|
title: "Transaction Date",
|
||||||
required: true,
|
requiredField: true),
|
||||||
displayText: controller.selectedTransactionDate.value == null
|
MySpacing.height(6),
|
||||||
? "Select Transaction Date"
|
GestureDetector(
|
||||||
: DateFormat('dd MMM yyyy')
|
|
||||||
.format(controller.selectedTransactionDate.value!),
|
|
||||||
onTap: () => controller.pickTransactionDate(context),
|
onTap: () => controller.pickTransactionDate(context),
|
||||||
));
|
child: AbsorbPointer(
|
||||||
|
child: CustomTextField(
|
||||||
|
controller: controller.transactionDateController,
|
||||||
|
hint: "Select Transaction Date",
|
||||||
|
validator: Validators.requiredField,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildLocationField() {
|
Widget _buildLocationField() {
|
||||||
@ -278,192 +431,33 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
|||||||
title: "Attachments",
|
title: "Attachments",
|
||||||
requiredField: true,
|
requiredField: true,
|
||||||
),
|
),
|
||||||
MySpacing.height(6),
|
MySpacing.height(10),
|
||||||
AttachmentsSection(
|
Obx(() {
|
||||||
|
if (controller.isProcessingAttachment.value) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color: contentTheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
"Processing image, please wait...",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: contentTheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AttachmentsSection(
|
||||||
attachments: controller.attachments,
|
attachments: controller.attachments,
|
||||||
existingAttachments: controller.existingAttachments,
|
existingAttachments: controller.existingAttachments,
|
||||||
onRemoveNew: controller.removeAttachment,
|
onRemoveNew: controller.removeAttachment,
|
||||||
onRemoveExisting: _confirmRemoveAttachment,
|
onRemoveExisting: (item) async {
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).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(
|
await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@ -484,75 +478,15 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
|||||||
message: 'Attachment has been removed.',
|
message: 'Attachment has been removed.',
|
||||||
type: SnackbarType.success,
|
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