371 lines
12 KiB
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,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|