From d15d9f22df003df5b0c41b9a570f36e9703af58d Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 31 Oct 2025 14:57:29 +0530 Subject: [PATCH] Refactor theme editor widget and dashboard screen layout; enhance user document screen with improved search, filter, and document management features --- .../document/user_document_controller.dart | 170 +- lib/helpers/theme/theme_editor_widget.dart | 15 - lib/view/dashboard/dashboard_screen.dart | 8 +- lib/view/document/user_document_screen.dart | 1391 +++++++++++------ 4 files changed, 999 insertions(+), 585 deletions(-) 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/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/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 66718b4..40794a8 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -62,6 +62,10 @@ class _DashboardScreenState extends State with UIMixin { children: [ _buildDashboardStats(context), MySpacing.height(24), + _buildAttendanceChartSection(), + MySpacing.height(24), + _buildProjectProgressChartSection(), + MySpacing.height(24), SizedBox( width: double.infinity, child: DashboardOverviewWidgets.teamsOverview(), @@ -71,10 +75,6 @@ class _DashboardScreenState extends State with UIMixin { width: double.infinity, child: DashboardOverviewWidgets.tasksOverview(), ), - MySpacing.height(24), - _buildAttendanceChartSection(), - MySpacing.height(24), - _buildProjectProgressChartSection(), ], ), ), diff --git a/lib/view/document/user_document_screen.dart b/lib/view/document/user_document_screen.dart index 941d730..ced6530 100644 --- a/lib/view/document/user_document_screen.dart +++ b/lib/view/document/user_document_screen.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:marco/controller/document/document_details_controller.dart'; @@ -11,7 +12,6 @@ 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_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; @@ -21,7 +21,6 @@ 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 { final String? entityId; final bool isEmployee; @@ -36,10 +35,17 @@ class UserDocumentsPage extends StatefulWidget { State createState() => _UserDocumentsPageState(); } -class _UserDocumentsPageState extends State with UIMixin { - final DocumentController docController = Get.put(DocumentController()); - final PermissionController permissionController = Get.put(PermissionController()); - final DocumentDetailsController controller = Get.put(DocumentDetailsController()); +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(); + DocumentDetailsController get detailsController => + Get.find(); String get entityTypeId => widget.isEmployee ? Permissions.employeeEntity @@ -52,366 +58,176 @@ 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) { - final uploadDate = DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal()); - final uploader = doc.uploadedBy.firstName.isNotEmpty - ? "Added by ${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}".trim() - : "Added by you"; + // ==================== UI BUILDERS ==================== - 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: () { - 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, - 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, - ), - ], - ), - ), - Obx(() { - // React to permission changes - return PopupMenuButton( - icon: const Icon(Icons.more_vert, color: Colors.black54), - onSelected: (value) async { - if (value == "delete") { - 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"); - } - }, - ), - ); - if (result == true) { - debugPrint("✅ Document deleted and removed from list"); - } - } else if (value == "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, - ); - } - } - }, - 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"), - ), - ], - ); - }), - ], - ), - ), - ), - ], - ); - } - - 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 _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), ), ], ), - ); - } - - Widget _buildFilterRow(BuildContext context) { - return Padding( - padding: MySpacing.xy(8, 8), - child: Row( - children: [ - Expanded( - child: SizedBox( - height: 35, - child: TextField( - controller: docController.searchController, - onChanged: (value) { - docController.searchQuery.value = value; + 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, ); }, - 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 = ''; + ); + }, + ), + 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, ); }, - ); - }, - ), - 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), - 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, + backgroundColor: Colors.red.shade50, + textColor: Colors.red.shade700, ), - ), - ), - ], - ); - }), - MySpacing.width(10), - 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); - }, - ), - ], - )), - ), - ], + const SizedBox(width: 8), + ], + _buildFilterButton(), + const SizedBox(width: 8), + _buildMoreOptionsButton(), + ], + ); + }), ), ), ], @@ -419,21 +235,191 @@ class _UserDocumentsPageState extends State with UIMixin { ); } - Widget _buildStatusHeader() { + 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 Container( + return AnimatedContainer( + duration: const Duration(milliseconds: 300), width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - color: Colors.red.shade50, + 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.visibility_off, color: Colors.red, size: 18), - const SizedBox(width: 8), - Text( - "Showing Deleted Documents", - style: TextStyle(color: Colors.red, fontWeight: FontWeight.w600), + 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, + ), ), ], ), @@ -441,34 +427,389 @@ class _UserDocumentsPageState extends State with UIMixin { }); } - Widget _buildBody(BuildContext context) { - return Obx(() { - if (permissionController.permissions.isEmpty) { - return Center(child: CircularProgressIndicator()); - } + 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 + ? "${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}".trim() + : "You"; - if (!permissionController.hasPermission(Permissions.viewDocument)) { - return Center( - 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, + final iconColor = _getDocumentTypeColor(doc.documentType.name); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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), + 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), + ], + ), ), - MySpacing.height(10), - MyText.bodySmall( - 'You do not have permission to view documents.', - color: Colors.grey, - ), - ], + ), + ), + ), + ], + ); + } + + Widget _buildDateHeader(String date) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: MyText.bodySmall( + date, + fontWeight: 700, + color: Colors.grey.shade700, + letterSpacing: 0.5, + ), + ); + } + + Widget _buildDocumentMenu(DocumentItem doc) { + return Obx(() { + final canDelete = + permissionController.hasPermission(Permissions.deleteDocument); + final canModify = + permissionController.hasPermission(Permissions.modifyDocument); + + // 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, + ) + ], + ), + ), + ); + } 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, + ); + }); + } + + 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: [ + 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, + ), + ), + const SizedBox(height: 24), + MyText.bodyLarge( + 'No documents found', + fontWeight: 600, + color: Colors.grey.shade700, + ), + const SizedBox(height: 8), + MyText.bodySmall( + '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(), @@ -477,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(); - }, + onBackPressed: () => Get.back(), ) : null, - body: _buildBody(context), - floatingActionButton: Obx(() { - if (permissionController.permissions.isEmpty) return SizedBox.shrink(); - - return 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, - ) - : SizedBox.shrink(); - }), + body: SafeArea( + child: _buildBody(), + ), + floatingActionButton: _buildFAB(), floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, ); }