From bc9fc4d6f1f6891cc3c4167badf516df7bfab874 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 31 Oct 2025 14:58:15 +0530 Subject: [PATCH] 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. --- .../attendance_screen_controller.dart | 11 +- .../document/user_document_controller.dart | 170 +- .../expense/add_expense_controller.dart | 17 +- lib/helpers/theme/theme_editor_widget.dart | 15 - .../widgets/time_stamp_image_helper.dart | 97 ++ .../expense/add_expense_bottom_sheet.dart | 748 ++++----- lib/view/document/user_document_screen.dart | 1452 +++++++++++------ 7 files changed, 1473 insertions(+), 1037 deletions(-) create mode 100644 lib/helpers/widgets/time_stamp_image_helper.dart diff --git a/lib/controller/attendance/attendance_screen_controller.dart b/lib/controller/attendance/attendance_screen_controller.dart index 501a94f..8e45d48 100644 --- a/lib/controller/attendance/attendance_screen_controller.dart +++ b/lib/controller/attendance/attendance_screen_controller.dart @@ -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 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; diff --git a/lib/controller/document/user_document_controller.dart b/lib/controller/document/user_document_controller.dart index f5f070e..fb3f734 100644 --- a/lib/controller/document/user_document_controller.dart +++ b/lib/controller/document/user_document_controller.dart @@ -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 = [].obs; - var filters = Rxn(); + // ==================== Observables ==================== + final isLoading = false.obs; + final documents = [].obs; + final filters = Rxn(); - // ✅ Selected filters (multi-select support) - var selectedUploadedBy = [].obs; - var selectedCategory = [].obs; - var selectedType = [].obs; - var selectedTag = [].obs; + // Selected filters (multi-select) + final selectedUploadedBy = [].obs; + final selectedCategory = [].obs; + final selectedType = [].obs; + final selectedTag = [].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(); - var endDate = Rxn(); + // Search + final searchQuery = ''.obs; + final searchController = TextEditingController(); - // ------------------ API Calls ----------------------- + // Additional filters + final isUploadedAt = true.obs; + final isVerified = RxnBool(); + final startDate = Rxn(); + final endDate = Rxn(); - /// 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 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 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 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 = ''; } } diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 139c2d9..75e0500 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -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 = [].obs; final allEmployees = [].obs; final employeeSearchResults = [].obs; + final isProcessingAttachment = false.obs; String? editingExpenseId; @@ -252,9 +254,22 @@ class AddExpenseController extends GetxController { Future 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 } } diff --git a/lib/helpers/theme/theme_editor_widget.dart b/lib/helpers/theme/theme_editor_widget.dart index 31466da..05b2561 100644 --- a/lib/helpers/theme/theme_editor_widget.dart +++ b/lib/helpers/theme/theme_editor_widget.dart @@ -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 { ], ), 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( diff --git a/lib/helpers/widgets/time_stamp_image_helper.dart b/lib/helpers/widgets/time_stamp_image_helper.dart new file mode 100644 index 0000000..954a205 --- /dev/null +++ b/lib/helpers/widgets/time_stamp_image_helper.dart @@ -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 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 + } + } +} diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 8592fe4..7d0ef4b 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -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 showAddExpenseBottomSheet({ @@ -19,7 +19,10 @@ Future showAddExpenseBottomSheet({ Map? existingExpense, }) { return Get.bottomSheet( - _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(); @@ -46,6 +50,95 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { final GlobalKey _expenseTypeDropdownKey = GlobalKey(); final GlobalKey _paymentModeDropdownKey = GlobalKey(); + /// Show employee list + Future _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 _showOptionList( + List options, + String Function(T) getLabel, + ValueChanged 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( + 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( + 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( + icon: Icons.work_outline, + title: "Project", + requiredField: true, + value: controller.selectedProject.value.isEmpty + ? "Select Project" + : controller.selectedProject.value, + onTap: () => _showOptionList( + controller.globalProjects.toList(), + (p) => p, + (val) => controller.selectedProject.value = val, + _projectDropdownKey, + ), + dropdownKey: _projectDropdownKey, + ), _gap(), - _buildExpenseTypeDropdown(), + + _buildDropdownField( + 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, + ), + + // 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( + 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, + ), + _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({ + 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? 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( - 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( - 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( - 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({ - 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? 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? 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 _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( - context: context, - position: _getPopupMenuPosition(_projectDropdownKey), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - items: displayList.map((opt) { - final isSpecial = opt == specialOption; - return PopupMenuItem( - 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 _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 _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 _showOptionList( - List options, - String Function(T) getLabel, - ValueChanged onSelected, - GlobalKey triggerKey, - ) async { - final selected = await showMenu( - context: context, - position: _getPopupMenuPosition(triggerKey), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - items: options - .map((opt) => PopupMenuItem( - 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"); - } - } } diff --git a/lib/view/document/user_document_screen.dart b/lib/view/document/user_document_screen.dart index 70fbce3..ced6530 100644 --- a/lib/view/document/user_document_screen.dart +++ b/lib/view/document/user_document_screen.dart @@ -1,24 +1,24 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:get/get.dart'; -import 'package:marco/controller/document/user_document_controller.dart'; -import 'package:marco/controller/project_controller.dart'; -import 'package:marco/helpers/widgets/my_spacing.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; -import 'package:marco/helpers/utils/permission_constants.dart'; -import 'package:marco/model/document/user_document_filter_bottom_sheet.dart'; -import 'package:marco/model/document/documents_list_model.dart'; import 'package:intl/intl.dart'; -import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; -import 'package:marco/model/document/document_upload_bottom_sheet.dart'; +import 'package:marco/controller/document/document_details_controller.dart'; import 'package:marco/controller/document/document_upload_controller.dart'; -import 'package:marco/view/document/document_details_page.dart'; +import 'package:marco/controller/document/user_document_controller.dart'; +import 'package:marco/controller/permission_controller.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/widgets/custom_app_bar.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; +import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; -import 'package:marco/controller/permission_controller.dart'; -import 'package:marco/controller/document/document_details_controller.dart'; -import 'dart:convert'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/model/document/document_upload_bottom_sheet.dart'; +import 'package:marco/model/document/documents_list_model.dart'; +import 'package:marco/model/document/user_document_filter_bottom_sheet.dart'; +import 'package:marco/view/document/document_details_page.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; class UserDocumentsPage extends StatefulWidget { @@ -35,12 +35,18 @@ class UserDocumentsPage extends StatefulWidget { State createState() => _UserDocumentsPageState(); } -class _UserDocumentsPageState extends State with UIMixin { - final DocumentController docController = Get.put(DocumentController()); - final PermissionController permissionController = +class _UserDocumentsPageState extends State + with UIMixin, SingleTickerProviderStateMixin { + late ScrollController _scrollController; + late AnimationController _fabAnimationController; + late Animation _fabScaleAnimation; + + DocumentController get docController => Get.find(); + PermissionController get permissionController => Get.find(); - final DocumentDetailsController controller = - Get.put(DocumentDetailsController()); + DocumentDetailsController get detailsController => + Get.find(); + String get entityTypeId => widget.isEmployee ? Permissions.employeeEntity : Permissions.projectEntity; @@ -52,188 +58,488 @@ class _UserDocumentsPageState extends State with UIMixin { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - docController.fetchFilters(entityTypeId); - docController.fetchDocuments( - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - reset: true, - ); - }); + + if (!Get.isRegistered()) Get.put(DocumentController()); + if (!Get.isRegistered()) + Get.put(PermissionController()); + if (!Get.isRegistered()) + Get.put(DocumentDetailsController()); + + _scrollController = ScrollController()..addListener(_onScroll); + + _fabAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + _fabScaleAnimation = CurvedAnimation( + parent: _fabAnimationController, + curve: Curves.easeInOut, + ); + _fabAnimationController.forward(); + + WidgetsBinding.instance.addPostFrameCallback((_) => _initializeData()); + } + + void _initializeData() { + docController.fetchFilters(entityTypeId); + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent * 0.8) { + if (!docController.isLoading.value && docController.hasMore.value) { + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + ); + } + } + + if (_scrollController.position.userScrollDirection == + ScrollDirection.reverse) { + if (_fabAnimationController.isCompleted) + _fabAnimationController.reverse(); + } else if (_scrollController.position.userScrollDirection == + ScrollDirection.forward) { + if (_fabAnimationController.isDismissed) + _fabAnimationController.forward(); + } } @override void dispose() { + _scrollController.dispose(); + _fabAnimationController.dispose(); + docController.searchController.dispose(); docController.documents.clear(); super.dispose(); } - Widget _buildDocumentTile(DocumentItem doc, bool showDateHeader) { + // ==================== UI BUILDERS ==================== + + Widget _buildSearchBar() { + return Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + controller: docController.searchController, + onChanged: (value) { + docController.searchQuery.value = value; + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + }, + style: const TextStyle(fontSize: 15), + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + prefixIcon: + const Icon(Icons.search_rounded, size: 22, color: Colors.grey), + suffixIcon: ValueListenableBuilder( + valueListenable: docController.searchController, + builder: (context, value, _) { + if (value.text.isEmpty) return const SizedBox.shrink(); + return IconButton( + icon: const Icon(Icons.clear_rounded, size: 20), + color: Colors.grey.shade600, + onPressed: () { + docController.searchController.clear(); + docController.searchQuery.value = ''; + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + }, + ); + }, + ), + hintText: 'Search by document name or type...', + hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 15), + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: BorderSide(color: contentTheme.primary, width: 2), + ), + ), + ), + ); + } + + Widget _buildFilterChips() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Obx(() { + final hasFilters = docController.hasActiveFilters(); + return Row( + children: [ + if (hasFilters) ...[ + _buildChip( + 'Clear Filters', + icon: Icons.close_rounded, + isSelected: false, + onTap: () { + docController.clearFilters(); + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + }, + backgroundColor: Colors.red.shade50, + textColor: Colors.red.shade700, + ), + const SizedBox(width: 8), + ], + _buildFilterButton(), + const SizedBox(width: 8), + _buildMoreOptionsButton(), + ], + ); + }), + ), + ), + ], + ), + ); + } + + Widget _buildChip( + String label, { + IconData? icon, + bool isSelected = false, + VoidCallback? onTap, + Color? backgroundColor, + Color? textColor, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(5), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: backgroundColor ?? + (isSelected + ? contentTheme.primary.withOpacity(0.1) + : Colors.white), + borderRadius: BorderRadius.circular(5), + border: Border.all( + color: isSelected ? contentTheme.primary : Colors.grey.shade300, + width: 1.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 16, + color: textColor ?? + (isSelected ? contentTheme.primary : Colors.grey.shade700), + ), + const SizedBox(width: 6), + ], + MyText.labelSmall( + label, + fontWeight: 600, + color: textColor ?? + (isSelected ? contentTheme.primary : Colors.grey.shade700), + ), + ], + ), + ), + ); + } + + Widget _buildFilterButton() { + return Obx(() { + final isFilterActive = docController.hasActiveFilters(); + return Stack( + clipBehavior: Clip.none, + children: [ + _buildChip( + 'Filters', + icon: Icons.tune_rounded, + isSelected: isFilterActive, + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => UserDocumentFilterBottomSheet( + entityId: resolvedEntityId, + entityTypeId: entityTypeId, + ), + ); + }, + ), + if (isFilterActive) + Positioned( + top: -4, + right: -4, + child: Container( + height: 10, + width: 10, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFFF1F1F1), width: 2), + ), + ), + ), + ], + ); + }); + } + + Widget _buildMoreOptionsButton() { + return PopupMenuButton( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + offset: const Offset(0, 40), + child: _buildChip( + 'Options', + icon: Icons.more_horiz_rounded, + ), + itemBuilder: (context) => [ + PopupMenuItem( + enabled: false, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: MyText.bodySmall( + 'Preferences', + fontWeight: 700, + color: Colors.grey.shade600, + ), + ), + const PopupMenuDivider(height: 1), + PopupMenuItem( + value: 'show_deleted', + enabled: false, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Obx(() => Row( + children: [ + Icon(Icons.visibility_off_outlined, + size: 20, color: Colors.grey.shade700), + const SizedBox(width: 12), + Expanded( + child: MyText.bodyMedium('Show Deleted', fontSize: 14), + ), + Switch.adaptive( + value: docController.showInactive.value, + activeColor: contentTheme.primary, + onChanged: (val) { + docController.showInactive.value = val; + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + Navigator.pop(context); + }, + ), + ], + )), + ), + ], + ); + } + + Widget _buildStatusBanner() { + return Obx(() { + if (!docController.showInactive.value) return const SizedBox.shrink(); + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + decoration: BoxDecoration( + color: Colors.red.shade50, + border: + Border(bottom: BorderSide(color: Colors.red.shade100, width: 1)), + ), + child: Row( + children: [ + Icon(Icons.info_outline_rounded, + color: Colors.red.shade700, size: 18), + const SizedBox(width: 10), + Expanded( + child: MyText.bodySmall( + 'Showing deleted documents', + fontWeight: 600, + color: Colors.red.shade700, + ), + ), + TextButton( + onPressed: () { + docController.showInactive.value = false; + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + }, + style: TextButton.styleFrom( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: MyText.labelMedium( + 'Hide', + fontWeight: 700, + color: Colors.red.shade700, + ), + ), + ], + ), + ); + }); + } + + Widget _buildDocumentCard(DocumentItem doc, bool showDateHeader) { final uploadDate = DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal()); - + final uploadTime = DateFormat("hh:mm a").format(doc.uploadedAt.toLocal()); final uploader = doc.uploadedBy.firstName.isNotEmpty - ? "Added by ${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}" - .trim() - : "Added by you"; + ? "${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}".trim() + : "You"; + + final iconColor = _getDocumentTypeColor(doc.documentType.name); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (showDateHeader) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: MyText.bodySmall( - uploadDate, - fontSize: 13, - fontWeight: 500, - color: Colors.grey, - ), - ), - InkWell( - onTap: () { - // 👉 Navigate to details page - Get.to(() => DocumentDetailsPage(documentId: doc.id)); - }, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: Colors.white, + if (showDateHeader) _buildDateHeader(uploadDate), + Hero( + tag: 'document_${doc.id}', + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Get.to( + () => DocumentDetailsPage(documentId: doc.id), + transition: Transition.rightToLeft, + duration: const Duration(milliseconds: 300), + ); + }, borderRadius: BorderRadius.circular(5), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.blue.shade50, - borderRadius: BorderRadius.circular(5), - ), - child: const Icon(Icons.description, color: Colors.blue), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodySmall( - doc.documentType.name, - fontSize: 13, - fontWeight: 600, - color: Colors.grey, - ), - MySpacing.height(2), - MyText.bodyMedium( - doc.name, - fontSize: 15, - fontWeight: 600, - color: Colors.black, - ), - MySpacing.height(2), - MyText.bodySmall( - uploader, - fontSize: 13, - color: Colors.grey, - ), - ], - ), - ), - PopupMenuButton( - icon: const Icon(Icons.more_vert, color: Colors.black54), - onSelected: (value) async { - if (value == "delete") { - // existing delete flow (unchanged) - final result = await showDialog( - context: context, - builder: (_) => ConfirmDialog( - title: "Delete Document", - message: - "Are you sure you want to delete \"${doc.name}\"?\nThis action cannot be undone.", - confirmText: "Delete", - cancelText: "Cancel", - icon: Icons.delete_forever, - confirmColor: Colors.redAccent, - onConfirm: () async { - final success = - await docController.toggleDocumentActive( - doc.id, - isActive: false, - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - ); - - if (success) { - showAppSnackbar( - title: "Deleted", - message: "Document deleted successfully", - type: SnackbarType.success, - ); - } else { - showAppSnackbar( - title: "Error", - message: "Failed to delete document", - type: SnackbarType.error, - ); - throw Exception( - "Failed to delete"); // keep dialog open - } - }, - ), - ); - if (result == true) { - debugPrint("✅ Document deleted and removed from list"); - } - } else if (value == "restore") { - // existing activate flow (unchanged) - final success = await docController.toggleDocumentActive( - doc.id, - isActive: true, - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - ); - - if (success) { - showAppSnackbar( - title: "Restored", - message: "Document reastored successfully", - type: SnackbarType.success, - ); - } else { - showAppSnackbar( - title: "Error", - message: "Failed to restore document", - type: SnackbarType.error, - ); - } - } - }, - itemBuilder: (context) => [ - if (doc.isActive && - permissionController - .hasPermission(Permissions.deleteDocument)) - const PopupMenuItem( - value: "delete", - child: Text("Delete"), - ) - else if (!doc.isActive && - permissionController - .hasPermission(Permissions.modifyDocument)) - const PopupMenuItem( - value: "restore", - child: Text("Restore"), - ), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + border: Border.all(color: Colors.grey.shade200, width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), ], ), - ], + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: iconColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + _getDocumentIcon(doc.documentType.name), + color: iconColor, + size: 24, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: iconColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: MyText.labelSmall( + doc.documentType.name, + fontWeight: 600, + color: iconColor, + letterSpacing: 0.3, + ), + ), + const SizedBox(height: 8), + MyText.bodyMedium( + doc.name, + fontWeight: 600, + color: Colors.black87, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + Row( + children: [ + Icon(Icons.person_outline_rounded, + size: 14, color: Colors.grey.shade600), + const SizedBox(width: 4), + Expanded( + child: MyText.bodySmall( + 'Added by $uploader', + color: Colors.grey.shade600, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + MyText.bodySmall( + uploadTime, + color: Colors.grey.shade500, + fontWeight: 500, + fontSize: 11, + ), + ], + ), + ], + ), + ), + _buildDocumentMenu(doc), + ], + ), + ), ), ), ), @@ -241,263 +547,269 @@ class _UserDocumentsPageState extends State with UIMixin { ); } - Widget _buildEmptyState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.inbox_outlined, size: 60, color: Colors.grey), - MySpacing.height(18), - MyText.titleMedium( - 'No documents found.', - fontWeight: 600, - color: Colors.grey, - ), - MySpacing.height(10), - MyText.bodySmall( - 'Try adjusting your filters or refresh to reload.', - color: Colors.grey, - ), - ], - ), - ); - } - - Widget _buildFilterRow(BuildContext context) { + Widget _buildDateHeader(String date) { return Padding( - padding: MySpacing.xy(8, 8), - child: Row( - children: [ - // 🔍 Search Bar - Expanded( - child: SizedBox( - height: 35, - child: TextField( - controller: docController.searchController, - onChanged: (value) { - docController.searchQuery.value = value; - docController.fetchDocuments( - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - reset: true, - ); - }, - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - prefixIcon: - const Icon(Icons.search, size: 20, color: Colors.grey), - suffixIcon: ValueListenableBuilder( - valueListenable: docController.searchController, - builder: (context, value, _) { - if (value.text.isEmpty) return const SizedBox.shrink(); - return IconButton( - icon: const Icon(Icons.clear, - size: 20, color: Colors.grey), - onPressed: () { - docController.searchController.clear(); - docController.searchQuery.value = ''; - docController.fetchDocuments( - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - reset: true, - ); - }, - ); - }, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(5), - borderSide: - BorderSide(color: contentTheme.primary, width: 1.5), - ), - hintText: 'Search documents...', - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(5), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(5), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - ), - ), - ), - ), - MySpacing.width(8), - - // 🛠️ Filter Icon with indicator - Obx(() { - final isFilterActive = docController.hasActiveFilters(); - return Stack( - children: [ - Container( - height: 35, - width: 35, - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(5), - ), - child: IconButton( - padding: EdgeInsets.zero, - constraints: BoxConstraints(), - icon: Icon( - Icons.tune, - size: 20, - color: Colors.black87, - ), - onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: - BorderRadius.vertical(top: Radius.circular(5)), - ), - builder: (_) => UserDocumentFilterBottomSheet( - entityId: resolvedEntityId, - entityTypeId: entityTypeId, - ), - ); - }, - ), - ), - if (isFilterActive) - Positioned( - top: 6, - right: 6, - child: Container( - height: 8, - width: 8, - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - ), - ), - ], - ); - }), - MySpacing.width(10), - - // ⋮ Menu (Show Inactive toggle) - Container( - height: 35, - width: 35, - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(5), - ), - child: PopupMenuButton( - padding: EdgeInsets.zero, - icon: - const Icon(Icons.more_vert, size: 20, color: Colors.black87), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), - itemBuilder: (context) => [ - const PopupMenuItem( - enabled: false, - height: 30, - child: Text( - "Preferences", - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.grey, - ), - ), - ), - PopupMenuItem( - value: 0, - enabled: false, - child: Obx(() => Row( - children: [ - const Icon(Icons.visibility_off_outlined, - size: 20, color: Colors.black87), - const SizedBox(width: 10), - const Expanded(child: Text('Show Deleted Documents')), - Switch.adaptive( - value: docController.showInactive.value, - activeColor: contentTheme.primary, - onChanged: (val) { - docController.showInactive.value = val; - docController.fetchDocuments( - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - reset: true, - ); - Navigator.pop(context); - }, - ), - ], - )), - ), - ], - ), - ), - ], + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: MyText.bodySmall( + date, + fontWeight: 700, + color: Colors.grey.shade700, + letterSpacing: 0.5, ), ); } - Widget _buildStatusHeader() { + Widget _buildDocumentMenu(DocumentItem doc) { return Obx(() { - final isInactive = docController.showInactive.value; - if (!isInactive) return const SizedBox.shrink(); // hide when active + final canDelete = + permissionController.hasPermission(Permissions.deleteDocument); + final canModify = + permissionController.hasPermission(Permissions.modifyDocument); - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - color: Colors.red.shade50, - child: Row( - children: [ - Icon( - Icons.visibility_off, - color: Colors.red, - size: 18, + // Build menu items list + final List> menuItems = []; + + if (doc.isActive && canDelete) { + menuItems.add( + PopupMenuItem( + value: "delete", + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.delete_outline_rounded, + size: 20, color: Colors.red.shade700), + const SizedBox(width: 12), + MyText.bodyMedium( + 'Delete', + color: Colors.red.shade700, + ) + ], ), - const SizedBox(width: 8), - Text( - "Showing Deleted Documents", - style: TextStyle( - color: Colors.red, - fontWeight: FontWeight.w600, - ), + ), + ); + } else if (!doc.isActive && canModify) { + menuItems.add( + PopupMenuItem( + value: "restore", + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.restore_rounded, + size: 20, color: contentTheme.primary), + const SizedBox(width: 12), + MyText.bodyMedium( + 'Restore', + color: contentTheme.primary, + ) + ], ), - ], - ), + ), + ); + } + + // If no menu items, return empty widget + if (menuItems.isEmpty) { + return const SizedBox.shrink(); + } + + return PopupMenuButton( + icon: Icon(Icons.more_vert_rounded, + color: Colors.grey.shade600, size: 20), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + offset: const Offset(-10, 30), + onSelected: (value) => _handleMenuAction(value, doc), + itemBuilder: (context) => menuItems, ); }); } - Widget _buildBody(BuildContext context) { - // 🔒 Check for viewDocument permission - if (!permissionController.hasPermission(Permissions.viewDocument)) { - return Center( + Future _handleMenuAction(String action, DocumentItem doc) async { + if (action == "delete") { + await showDialog( + context: context, + builder: (_) => ConfirmDialog( + title: "Delete Document", + message: + "Are you sure you want to delete \"${doc.name}\"?\n\nThis action cannot be undone.", + confirmText: "Delete", + cancelText: "Cancel", + icon: Icons.delete_forever_rounded, + confirmColor: Colors.redAccent, + onConfirm: () async { + final success = await docController.toggleDocumentActive( + doc.id, + isActive: false, + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + ); + + if (success) { + showAppSnackbar( + title: "Deleted", + message: "Document deleted successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to delete document", + type: SnackbarType.error, + ); + throw Exception("Failed to delete"); + } + }, + ), + ); + } else if (action == "restore") { + final success = await docController.toggleDocumentActive( + doc.id, + isActive: true, + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + ); + + if (success) { + showAppSnackbar( + title: "Restored", + message: "Document restored successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to restore document", + type: SnackbarType.error, + ); + } + } + } + + Widget _buildEmptyState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.lock_outline, size: 60, color: Colors.grey), - MySpacing.height(18), - MyText.titleMedium( - 'Access Denied', - fontWeight: 600, - color: Colors.grey, + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.grey.shade100, + shape: BoxShape.circle, + ), + child: Icon( + Icons.folder_open_rounded, + size: 64, + color: Colors.grey.shade400, + ), ), - MySpacing.height(10), + const SizedBox(height: 24), + MyText.bodyLarge( + 'No documents found', + fontWeight: 600, + color: Colors.grey.shade700, + ), + const SizedBox(height: 8), MyText.bodySmall( - 'You do not have permission to view documents.', - color: Colors.grey, + 'Try adjusting your filters or\nadd a new document to get started', + color: Colors.grey.shade600, + height: 1.5, + textAlign: TextAlign.center, ), ], ), - ); - } + ), + ); + } + Widget _buildLoadingIndicator() { + return const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator(), + ), + ); + } + + Widget _buildNoMoreIndicator() { + return Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + height: 1, + width: 40, + color: Colors.grey.shade300, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: MyText.bodySmall( + 'No more documents', + fontWeight: 500, + )), + Container( + height: 1, + width: 40, + color: Colors.grey.shade300, + ), + ], + ), + ), + ); + } + + Widget _buildPermissionDenied() { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.red.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.lock_outline_rounded, + size: 64, + color: Colors.red.shade300, + ), + ), + const SizedBox(height: 24), + MyText.bodyLarge( + 'Access Denied', + fontWeight: 600, + color: Colors.grey.shade700, + ), + const SizedBox(height: 8), + MyText.bodySmall( + 'You don\'t have permission\nto view documents', + color: Colors.grey.shade600, + height: 1.5, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildBody() { return Obx(() { + // Check permissions + if (permissionController.permissions.isEmpty) { + return _buildLoadingIndicator(); + } + + if (!permissionController.hasPermission(Permissions.viewDocument)) { + return _buildPermissionDenied(); + } + + // Show skeleton loader if (docController.isLoading.value && docController.documents.isEmpty) { return SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), @@ -506,149 +818,205 @@ class _UserDocumentsPageState extends State with UIMixin { } final docs = docController.documents; - return SafeArea( - child: Column( - children: [ - _buildFilterRow(context), - _buildStatusHeader(), - Expanded( - child: MyRefreshIndicator( - onRefresh: () async { - final combinedFilter = { - 'uploadedByIds': docController.selectedUploadedBy.toList(), - 'documentCategoryIds': - docController.selectedCategory.toList(), - 'documentTypeIds': docController.selectedType.toList(), - 'documentTagIds': docController.selectedTag.toList(), - }; - await docController.fetchDocuments( - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - filter: jsonEncode(combinedFilter), - reset: true, - ); - }, - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: docs.isEmpty - ? null - : const EdgeInsets.fromLTRB(0, 0, 0, 80), - children: docs.isEmpty - ? [ - SizedBox( - height: MediaQuery.of(context).size.height * 0.6, - child: _buildEmptyState(), - ), - ] - : [ - ...docs.asMap().entries.map((entry) { - final index = entry.key; - final doc = entry.value; + return Column( + children: [ + _buildSearchBar(), + _buildFilterChips(), + _buildStatusBanner(), + Expanded( + child: MyRefreshIndicator( + onRefresh: () async { + final combinedFilter = { + 'uploadedByIds': docController.selectedUploadedBy.toList(), + 'documentCategoryIds': + docController.selectedCategory.toList(), + 'documentTypeIds': docController.selectedType.toList(), + 'documentTagIds': docController.selectedTag.toList(), + }; - final currentDate = DateFormat("dd MMM yyyy") - .format(doc.uploadedAt.toLocal()); - final prevDate = index > 0 - ? DateFormat("dd MMM yyyy").format( - docs[index - 1].uploadedAt.toLocal()) - : null; + await docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + filter: jsonEncode(combinedFilter), + reset: true, + ); + }, + child: docs.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: _buildEmptyState(), + ), + ], + ) + : ListView.builder( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 100, top: 8), + itemCount: docs.length + 1, + itemBuilder: (context, index) { + if (index == docs.length) { + return Obx(() { + if (docController.isLoading.value) { + return _buildLoadingIndicator(); + } + if (!docController.hasMore.value && + docs.isNotEmpty) { + return _buildNoMoreIndicator(); + } + return const SizedBox.shrink(); + }); + } - final showDateHeader = currentDate != prevDate; + final doc = docs[index]; + final currentDate = DateFormat("dd MMM yyyy") + .format(doc.uploadedAt.toLocal()); + final prevDate = index > 0 + ? DateFormat("dd MMM yyyy") + .format(docs[index - 1].uploadedAt.toLocal()) + : null; + final showDateHeader = currentDate != prevDate; - return _buildDocumentTile(doc, showDateHeader); - }), - if (docController.isLoading.value) - const Padding( - padding: EdgeInsets.all(12), - child: Center(child: CircularProgressIndicator()), - ), - if (!docController.hasMore.value) - Padding( - padding: const EdgeInsets.all(12), - child: Center( - child: MyText.bodySmall( - "No more documents", - color: Colors.grey, - ), - ), - ), - ], - ), - ), + return _buildDocumentCard(doc, showDateHeader); + }, + ), ), - ], + ), + ], + ); + }); + } + + Widget _buildFAB() { + return Obx(() { + if (permissionController.permissions.isEmpty) { + return const SizedBox.shrink(); + } + + if (!permissionController.hasPermission(Permissions.uploadDocument)) { + return const SizedBox.shrink(); + } + + return ScaleTransition( + scale: _fabScaleAnimation, + child: FloatingActionButton.extended( + onPressed: _showUploadBottomSheet, + elevation: 4, + highlightElevation: 8, + backgroundColor: contentTheme.primary, + foregroundColor: Colors.white, + icon: const Icon(Icons.add_rounded, size: 24), + label: const Text( + 'Add Document', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), ), ); }); } + void _showUploadBottomSheet() { + final uploadController = Get.put(DocumentUploadController()); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => DocumentUploadBottomSheet( + isEmployee: widget.isEmployee, + onSubmit: (data) async { + final success = await uploadController.uploadDocument( + name: data["name"], + description: data["description"], + documentId: data["documentId"], + entityId: resolvedEntityId, + documentTypeId: data["documentTypeId"], + fileName: data["attachment"]["fileName"], + base64Data: data["attachment"]["base64Data"], + contentType: data["attachment"]["contentType"], + fileSize: data["attachment"]["fileSize"], + ); + + if (success) { + Navigator.pop(context); + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + showAppSnackbar( + title: "Success", + message: "Document uploaded successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Upload failed, please try again", + type: SnackbarType.error, + ); + } + }, + ), + ); + } + + // Helper methods for document type styling + Color _getDocumentTypeColor(String type) { + final lowerType = type.toLowerCase(); + if (lowerType.contains('contract') || lowerType.contains('agreement')) { + return Colors.purple; + } else if (lowerType.contains('invoice') || lowerType.contains('receipt')) { + return Colors.green; + } else if (lowerType.contains('report')) { + return Colors.orange; + } else if (lowerType.contains('certificate')) { + return Colors.blue; + } else if (lowerType.contains('id') || lowerType.contains('identity')) { + return Colors.red; + } else { + return Colors.blueGrey; + } + } + + IconData _getDocumentIcon(String type) { + final lowerType = type.toLowerCase(); + if (lowerType.contains('contract') || lowerType.contains('agreement')) { + return Icons.article_rounded; + } else if (lowerType.contains('invoice') || lowerType.contains('receipt')) { + return Icons.receipt_long_rounded; + } else if (lowerType.contains('report')) { + return Icons.assessment_rounded; + } else if (lowerType.contains('certificate')) { + return Icons.workspace_premium_rounded; + } else if (lowerType.contains('id') || lowerType.contains('identity')) { + return Icons.badge_rounded; + } else { + return Icons.description_rounded; + } + } + @override Widget build(BuildContext context) { - final bool showAppBar = !widget.isEmployee; - return Scaffold( backgroundColor: const Color(0xFFF1F1F1), - appBar: showAppBar + appBar: !widget.isEmployee ? CustomAppBar( title: 'Documents', - onBackPressed: () { - Get.back(); - }, - ) - : null, - body: _buildBody(context), - floatingActionButton: permissionController - .hasPermission(Permissions.uploadDocument) - ? FloatingActionButton.extended( - onPressed: () { - final uploadController = Get.put(DocumentUploadController()); - - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => DocumentUploadBottomSheet( - isEmployee: widget.isEmployee, - onSubmit: (data) async { - final success = await uploadController.uploadDocument( - name: data["name"], - description: data["description"], - documentId: data["documentId"], - entityId: resolvedEntityId, - documentTypeId: data["documentTypeId"], - fileName: data["attachment"]["fileName"], - base64Data: data["attachment"]["base64Data"], - contentType: data["attachment"]["contentType"], - fileSize: data["attachment"]["fileSize"], - ); - - if (success) { - Navigator.pop(context); - docController.fetchDocuments( - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - reset: true, - ); - } else { - showAppSnackbar( - title: "Error", - message: "Upload failed, please try again", - type: SnackbarType.error, - ); - } - }, - ), - ); - }, - icon: const Icon(Icons.add, color: Colors.white), - label: MyText.bodyMedium( - "Add Document", - color: Colors.white, - fontWeight: 600, - ), - backgroundColor: contentTheme.primary, + onBackPressed: () => Get.back(), ) : null, + body: SafeArea( + child: _buildBody(), + ), + floatingActionButton: _buildFAB(), floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, ); }