Add file metadata display in viewer and upload progress snackbar

- Backend: Add modified_by column to files table
- Backend: Track who modified files via WOPI PutFile
- Backend: Return fileInfo (name, size, lastModified, modifiedByName) in view response
- Flutter: Update DocumentCapabilities model with FileInfo
- Flutter: Display actual last modified date and user in document viewer
- Flutter: Show upload progress snackbar with percentage that auto-dismisses on completion
This commit is contained in:
Leon Bösche
2026-01-13 16:48:25 +01:00
parent 6ce43a3c9b
commit 37e0520af0
5 changed files with 81 additions and 12 deletions

View File

@@ -26,7 +26,12 @@ class DocumentViewerReady extends DocumentViewerState {
});
@override
List<Object> get props => [viewUrl, caps, token, if (fileInfo != null) fileInfo!];
List<Object> get props => [
viewUrl,
caps,
token,
if (fileInfo != null) fileInfo!,
];
}
class DocumentViewerError extends DocumentViewerState {

View File

@@ -20,8 +20,8 @@ class FileInfo extends Equatable {
return FileInfo(
name: json['name'] ?? '',
size: json['size'] ?? 0,
lastModified: json['lastModified'] != null
? DateTime.tryParse(json['lastModified'])
lastModified: json['lastModified'] != null
? DateTime.tryParse(json['lastModified'])
: null,
modifiedByName: json['modifiedByName'],
);

View File

@@ -17,7 +17,13 @@ class ViewerSession extends Equatable {
});
@override
List<Object?> get props => [viewUrl, capabilities, token, expiresAt, fileInfo];
List<Object?> get props => [
viewUrl,
capabilities,
token,
expiresAt,
fileInfo,
];
factory ViewerSession.fromJson(Map<String, dynamic> json) {
return ViewerSession(
@@ -25,7 +31,9 @@ class ViewerSession extends Equatable {
capabilities: DocumentCapabilities.fromJson(json['capabilities']),
token: json['token'],
expiresAt: DateTime.parse(json['expiresAt']),
fileInfo: json['fileInfo'] != null ? FileInfo.fromJson(json['fileInfo']) : null,
fileInfo: json['fileInfo'] != null
? FileInfo.fromJson(json['fileInfo'])
: null,
);
}
}

View File

@@ -118,9 +118,11 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
final modifiedDate = fileInfo.lastModified;
final modifiedBy = fileInfo.modifiedByName;
if (modifiedDate != null) {
final formattedDate = '${modifiedDate.day.toString().padLeft(2, '0')}.${modifiedDate.month.toString().padLeft(2, '0')}.${modifiedDate.year} ${modifiedDate.hour.toString().padLeft(2, '0')}:${modifiedDate.minute.toString().padLeft(2, '0')}';
final formattedDate =
'${modifiedDate.day.toString().padLeft(2, '0')}.${modifiedDate.month.toString().padLeft(2, '0')}.${modifiedDate.year} ${modifiedDate.hour.toString().padLeft(2, '0')}:${modifiedDate.minute.toString().padLeft(2, '0')}';
if (modifiedBy != null && modifiedBy.isNotEmpty) {
lastModifiedText = 'Last modified: $formattedDate by $modifiedBy';
lastModifiedText =
'Last modified: $formattedDate by $modifiedBy';
} else {
lastModifiedText = 'Last modified: $formattedDate';
}
@@ -581,9 +583,11 @@ class _DocumentViewerState extends State<DocumentViewer> {
final modifiedDate = fileInfo.lastModified;
final modifiedBy = fileInfo.modifiedByName;
if (modifiedDate != null) {
final formattedDate = '${modifiedDate.day.toString().padLeft(2, '0')}.${modifiedDate.month.toString().padLeft(2, '0')}.${modifiedDate.year} ${modifiedDate.hour.toString().padLeft(2, '0')}:${modifiedDate.minute.toString().padLeft(2, '0')}';
final formattedDate =
'${modifiedDate.day.toString().padLeft(2, '0')}.${modifiedDate.month.toString().padLeft(2, '0')}.${modifiedDate.year} ${modifiedDate.hour.toString().padLeft(2, '0')}:${modifiedDate.minute.toString().padLeft(2, '0')}';
if (modifiedBy != null && modifiedBy.isNotEmpty) {
lastModifiedText = 'Last modified: $formattedDate by $modifiedBy';
lastModifiedText =
'Last modified: $formattedDate by $modifiedBy';
} else {
lastModifiedText = 'Last modified: $formattedDate';
}

View File

@@ -37,6 +37,7 @@ class _FileExplorerState extends State<FileExplorer> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
final Map<String, bool> _hovered = {};
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? _uploadSnackBarController;
String _getParentPath(String path) {
if (path == '/') return '/';
@@ -684,16 +685,63 @@ class _FileExplorerState extends State<FileExplorer> {
return BlocListener<UploadBloc, UploadState>(
listener: (context, uploadState) {
if (uploadState is UploadInProgress) {
// Calculate overall progress
final uploads = uploadState.uploads;
final activeUploads = uploads.where((u) => !u.isCompleted && u.error == null).toList();
final completedUploads = uploads.where((u) => u.isCompleted).toList();
// Show error if any upload failed
for (final upload in uploadState.uploads) {
for (final upload in uploads) {
if (upload.error != null && upload.error!.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Upload failed: ${upload.error}')),
);
}
}
final hasCompleted = uploadState.uploads.any((u) => u.isCompleted);
if (hasCompleted) {
// Show progress snackbar for active uploads
if (activeUploads.isNotEmpty) {
final totalProgress = uploads.fold<double>(0, (sum, u) => sum + u.progress) / uploads.length;
final fileName = activeUploads.length == 1
? activeUploads.first.fileName
: '${activeUploads.length} files';
// Dismiss previous snackbar and show new one
_uploadSnackBarController?.close();
_uploadSnackBarController = ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
value: totalProgress,
strokeWidth: 2,
color: Colors.white,
backgroundColor: Colors.white24,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Uploading $fileName... ${(totalProgress * 100).toInt()}%',
overflow: TextOverflow.ellipsis,
),
),
],
),
duration: const Duration(days: 1), // Keep showing until dismissed
backgroundColor: AppTheme.surfaceColor,
),
);
}
// Dismiss snackbar and refresh when all uploads complete
if (completedUploads.length == uploads.length && uploads.isNotEmpty) {
_uploadSnackBarController?.close();
_uploadSnackBarController = null;
final fbState = context.read<FileBrowserBloc>().state;
String currentPath = '/';
if (fbState is DirectoryLoaded) currentPath = fbState.currentPath;
@@ -702,6 +750,10 @@ class _FileExplorerState extends State<FileExplorer> {
LoadDirectory(orgId: widget.orgId, path: currentPath),
);
}
} else if (uploadState is UploadInitial) {
// Upload finished or reset - dismiss snackbar
_uploadSnackBarController?.close();
_uploadSnackBarController = null;
}
},
child: BlocBuilder<FileBrowserBloc, FileBrowserState>(