import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/utils/formatters.dart'; import '../../../../shared/models/grn_model.dart'; import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/widgets/api_feedback.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart'; import '../providers/grn_provider.dart'; import '../../../../shared/widgets/app_toast.dart'; const _allowedExtensions = ['pdf', 'jpg', 'jpeg', 'png', 'webp']; String _formatFileSize(int? bytes) { if (bytes == null || bytes <= 0) return '—'; if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) { return '${(bytes / 1024).toStringAsFixed(1)} KB'; } return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } IconData _fileIcon(GrnAttachmentModel attachment) { if (attachment.isPdf) return Icons.picture_as_pdf_outlined; if (attachment.isImage) return Icons.image_outlined; return Icons.insert_drive_file_outlined; } /// Attachments section for GRN detail — list / upload / download / delete. class GrnAttachmentsCard extends ConsumerStatefulWidget { const GrnAttachmentsCard({ super.key, required this.grn, required this.canUpload, required this.canDelete, }); final GrnModel grn; final bool canUpload; final bool canDelete; @override ConsumerState createState() => _GrnAttachmentsCardState(); } class _GrnAttachmentsCardState extends ConsumerState { bool _isUploading = false; String? _busyAttachmentId; bool get _canManage => widget.grn.canManageAttachments && (widget.canUpload || widget.canDelete); Future _upload() async { if (!widget.canUpload || !widget.grn.canManageAttachments) return; final result = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: _allowedExtensions, withData: true, ); if (result == null || result.files.isEmpty) return; final file = result.files.single; final bytes = file.bytes; if (bytes == null || bytes.isEmpty) { if (!mounted) return; showAppToastFromSnackBar(context, const SnackBar(content: Text('Could not read the selected file')), ); return; } final ext = (file.extension ?? '').toLowerCase(); if (!_allowedExtensions.contains(ext)) { if (!mounted) return; showAppToastFromSnackBar(context, const SnackBar( content: Text('Only PDF, JPEG, PNG, and WebP files are allowed'), ), ); return; } setState(() => _isUploading = true); try { await ref.read(grnDetailProvider(widget.grn.id).notifier).uploadAttachment( bytes: bytes, filename: file.name, ); if (!mounted) return; showAppToastFromSnackBar(context, SnackBar(content: Text('${file.name} uploaded')), ); } catch (e) { if (!mounted) return; showAppToastFromSnackBar( context, SnackBar(content: Text(errorDisplayMessage(e))), ); } finally { if (mounted) setState(() => _isUploading = false); } } Future _download(GrnAttachmentModel attachment) async { setState(() => _busyAttachmentId = attachment.id); try { final bytes = await ref .read(grnDetailProvider(widget.grn.id).notifier) .downloadAttachment(attachment.id); if (bytes.isEmpty) throw Exception('Empty file response'); await downloadFile( bytes: bytes, fileName: attachment.fileName ?? 'grn-attachment-${attachment.id}', ); } catch (e) { if (!mounted) return; showAppToastFromSnackBar( context, SnackBar(content: Text(errorDisplayMessage(e))), ); } finally { if (mounted) setState(() => _busyAttachmentId = null); } } Future _delete(GrnAttachmentModel attachment) async { if (!widget.canDelete || !widget.grn.canManageAttachments) return; final confirmed = await showAppConfirmationDialog( context: context, title: 'Delete attachment', message: 'Delete ${attachment.fileName ?? 'this file'}? This cannot be undone.', confirmLabel: 'Delete', isDestructive: true, ); if (confirmed != true || !mounted) return; setState(() => _busyAttachmentId = attachment.id); try { await ref .read(grnDetailProvider(widget.grn.id).notifier) .deleteAttachment(attachment.id); if (!mounted) return; showAppToastFromSnackBar(context, const SnackBar(content: Text('Attachment deleted')), ); } catch (e) { if (!mounted) return; showAppToastFromSnackBar( context, SnackBar(content: Text(errorDisplayMessage(e))), ); } finally { if (mounted) setState(() => _busyAttachmentId = null); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final attachments = widget.grn.attachments; final showUpload = widget.canUpload && widget.grn.canManageAttachments; return Container( width: double.infinity, padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(12), border: Border.all( color: theme.colorScheme.outline.withValues(alpha: 0.2), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Text( 'ATTACHMENTS · ${attachments.length}', style: theme.textTheme.labelMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, letterSpacing: 0.8, ), ), const Spacer(), if (showUpload) TextButton.icon( onPressed: _isUploading ? null : _upload, icon: _isUploading ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.upload_file_outlined, size: 18), label: Text(_isUploading ? 'Uploading…' : 'Upload'), ), ], ), const SizedBox(height: 4), Text( showUpload ? 'PDF, JPEG, PNG, or WebP · upload/delete only while Purchase Receipt is Posted' : 'Supporting documents for this Purchase Receipt', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 12), if (attachments.isEmpty) Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all( color: theme.colorScheme.outline.withValues(alpha: 0.15), ), ), child: Text( showUpload ? 'No attachments yet. Upload a vendor invoice, LR copy, or receipt photo.' : 'No attachments', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ) else ...attachments.asMap().entries.map((entry) { final index = entry.key; final attachment = entry.value; return Padding( padding: EdgeInsets.only( bottom: index == attachments.length - 1 ? 0 : 8, ), child: _AttachmentRow( attachment: attachment, isBusy: _busyAttachmentId == attachment.id, canDelete: widget.canDelete && widget.grn.canManageAttachments, onDownload: () => _download(attachment), onDelete: () => _delete(attachment), ), ); }), if (!_canManage && !widget.grn.canManageAttachments && attachments.isNotEmpty) ...[ const SizedBox(height: 8), Text( 'This Purchase Receipt is cancelled — attachments are view/download only.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ], ), ); } } class _AttachmentRow extends StatelessWidget { const _AttachmentRow({ required this.attachment, required this.isBusy, required this.canDelete, required this.onDownload, required this.onDelete, }); final GrnAttachmentModel attachment; final bool isBusy; final bool canDelete; final VoidCallback onDownload; final VoidCallback onDelete; @override Widget build(BuildContext context) { final theme = Theme.of(context); final borderColor = theme.colorScheme.outline.withValues(alpha: 0.15); final metaParts = [ _formatFileSize(attachment.fileSize), if (attachment.uploadedByName?.trim().isNotEmpty == true) attachment.uploadedByName!.trim(), if (attachment.createdAt != null) DateFormatter.displayDateTime(attachment.createdAt), ]; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all(color: borderColor), ), child: Row( children: [ Icon( _fileIcon(attachment), size: 22, color: theme.colorScheme.primary, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( attachment.fileName ?? 'Attachment #${attachment.id}', style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (metaParts.isNotEmpty) ...[ const SizedBox(height: 2), Text( metaParts.join(' · '), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ], ), ), if (isBusy) const Padding( padding: EdgeInsets.symmetric(horizontal: 8), child: SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ), ) else ...[ IconButton( tooltip: 'Download', onPressed: onDownload, icon: const Icon(Icons.download_outlined, size: 20), visualDensity: VisualDensity.compact, ), if (canDelete) IconButton( tooltip: 'Delete', onPressed: onDelete, icon: Icon( Icons.delete_outline, size: 20, color: theme.colorScheme.error, ), visualDensity: VisualDensity.compact, ), ], ], ), ); } }