bharat_erp/lib/modules/grn/presentation/widgets/grn_attachments_card.dart

371 lines
12 KiB
Dart

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<GrnAttachmentsCard> createState() => _GrnAttachmentsCardState();
}
class _GrnAttachmentsCardState extends ConsumerState<GrnAttachmentsCard> {
bool _isUploading = false;
String? _busyAttachmentId;
bool get _canManage =>
widget.grn.canManageAttachments && (widget.canUpload || widget.canDelete);
Future<void> _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<void> _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<void> _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 = <String>[
_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,
),
],
],
),
);
}
}