Compare commits

...

2 Commits

Author SHA1 Message Date
Leon Bösche
d2c26e6203 Style upload progress snackbar with accent color (nyan) 2026-01-13 16:49:17 +01:00
Leon Bösche
37e0520af0 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
2026-01-13 16:48:25 +01:00
5 changed files with 82 additions and 12 deletions

View File

@@ -26,7 +26,12 @@ class DocumentViewerReady extends DocumentViewerState {
}); });
@override @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 { class DocumentViewerError extends DocumentViewerState {

View File

@@ -17,7 +17,13 @@ class ViewerSession extends Equatable {
}); });
@override @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) { factory ViewerSession.fromJson(Map<String, dynamic> json) {
return ViewerSession( return ViewerSession(
@@ -25,7 +31,9 @@ class ViewerSession extends Equatable {
capabilities: DocumentCapabilities.fromJson(json['capabilities']), capabilities: DocumentCapabilities.fromJson(json['capabilities']),
token: json['token'], token: json['token'],
expiresAt: DateTime.parse(json['expiresAt']), 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 modifiedDate = fileInfo.lastModified;
final modifiedBy = fileInfo.modifiedByName; final modifiedBy = fileInfo.modifiedByName;
if (modifiedDate != null) { 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) { if (modifiedBy != null && modifiedBy.isNotEmpty) {
lastModifiedText = 'Last modified: $formattedDate by $modifiedBy'; lastModifiedText =
'Last modified: $formattedDate by $modifiedBy';
} else { } else {
lastModifiedText = 'Last modified: $formattedDate'; lastModifiedText = 'Last modified: $formattedDate';
} }
@@ -581,9 +583,11 @@ class _DocumentViewerState extends State<DocumentViewer> {
final modifiedDate = fileInfo.lastModified; final modifiedDate = fileInfo.lastModified;
final modifiedBy = fileInfo.modifiedByName; final modifiedBy = fileInfo.modifiedByName;
if (modifiedDate != null) { 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) { if (modifiedBy != null && modifiedBy.isNotEmpty) {
lastModifiedText = 'Last modified: $formattedDate by $modifiedBy'; lastModifiedText =
'Last modified: $formattedDate by $modifiedBy';
} else { } else {
lastModifiedText = 'Last modified: $formattedDate'; lastModifiedText = 'Last modified: $formattedDate';
} }

View File

@@ -37,6 +37,7 @@ class _FileExplorerState extends State<FileExplorer> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
String _searchQuery = ''; String _searchQuery = '';
final Map<String, bool> _hovered = {}; final Map<String, bool> _hovered = {};
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? _uploadSnackBarController;
String _getParentPath(String path) { String _getParentPath(String path) {
if (path == '/') return '/'; if (path == '/') return '/';
@@ -684,16 +685,64 @@ class _FileExplorerState extends State<FileExplorer> {
return BlocListener<UploadBloc, UploadState>( return BlocListener<UploadBloc, UploadState>(
listener: (context, uploadState) { listener: (context, uploadState) {
if (uploadState is UploadInProgress) { 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 // Show error if any upload failed
for (final upload in uploadState.uploads) { for (final upload in uploads) {
if (upload.error != null && upload.error!.isNotEmpty) { if (upload.error != null && upload.error!.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Upload failed: ${upload.error}')), 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: AppTheme.accentColor,
backgroundColor: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Uploading $fileName... ${(totalProgress * 100).toInt()}%',
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppTheme.primaryText),
),
),
],
),
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; final fbState = context.read<FileBrowserBloc>().state;
String currentPath = '/'; String currentPath = '/';
if (fbState is DirectoryLoaded) currentPath = fbState.currentPath; if (fbState is DirectoryLoaded) currentPath = fbState.currentPath;
@@ -702,6 +751,10 @@ class _FileExplorerState extends State<FileExplorer> {
LoadDirectory(orgId: widget.orgId, path: currentPath), 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>( child: BlocBuilder<FileBrowserBloc, FileBrowserState>(