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:on_field_work/controller/document/document_details_controller.dart'; import 'package:on_field_work/controller/document/document_upload_controller.dart'; import 'package:on_field_work/controller/document/user_document_controller.dart'; import 'package:on_field_work/controller/permission_controller.dart'; import 'package:on_field_work/controller/project_controller.dart'; import 'package:on_field_work/helpers/utils/permission_constants.dart'; import 'package:on_field_work/helpers/widgets/custom_app_bar.dart'; import 'package:on_field_work/helpers/widgets/my_confirmation_dialog.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_text.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart'; import 'package:on_field_work/model/document/document_upload_bottom_sheet.dart'; import 'package:on_field_work/model/document/documents_list_model.dart'; import 'package:on_field_work/model/document/user_document_filter_bottom_sheet.dart'; import 'package:on_field_work/view/document/document_details_page.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; class UserDocumentsPage extends StatefulWidget { final String? entityId; final bool isEmployee; const UserDocumentsPage({ super.key, this.entityId, this.isEmployee = false, }); @override State createState() => _UserDocumentsPageState(); } 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 : Permissions.projectEntity; String get resolvedEntityId => widget.isEmployee ? widget.entityId ?? "" : Get.find().selectedProject?.id ?? ""; @override void initState() { super.initState(); 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(); } // ==================== 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 ? "${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}".trim() : "You"; 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), ], ), ), ), ), ), ], ); } 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(), child: SkeletonLoaders.documentSkeletonLoader(), ); } final docs = docController.documents; 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(), }; 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 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 _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) { return Scaffold( backgroundColor: const Color(0xFFF1F1F1), appBar: !widget.isEmployee ? CustomAppBar( title: 'Documents', onBackPressed: () => Get.back(), ) : null, body: SafeArea( child: _buildBody(), ), floatingActionButton: _buildFAB(), floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, ); } }