From d9fcd9f624a2865361d8bcc9a5b8613169fb8801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 13 May 2026 20:05:54 +0200 Subject: [PATCH] implemented file thumbnails and enhanced file type icons, added reusable FileLeading widget, and updated search to support previews --- .../search/search_files_response.dart | 42 ++- lib/view/pages/files/data/file_type_icon.dart | 242 ++++++++++++++++++ .../files/widgets/file_details_sheet.dart | 6 +- .../pages/files/widgets/file_element.dart | 5 +- .../pages/files/widgets/file_leading.dart | 51 ++++ .../share_intent/share_folder_picker.dart | 5 +- 6 files changed, 341 insertions(+), 10 deletions(-) create mode 100644 lib/view/pages/files/data/file_type_icon.dart create mode 100644 lib/view/pages/files/widgets/file_leading.dart diff --git a/lib/api/marianumcloud/search/search_files_response.dart b/lib/api/marianumcloud/search/search_files_response.dart index d9b3d72..9bc8a0e 100644 --- a/lib/api/marianumcloud/search/search_files_response.dart +++ b/lib/api/marianumcloud/search/search_files_response.dart @@ -83,9 +83,49 @@ class SearchFilesEntry { return null; } + /// Derives a filename with its extension from the WebDAV-relative path. + /// The provider's `title` is often the display name *without* the + /// extension, which would defeat extension-based icon mapping. + String _nameFromPath(String path) { + final stripped = path.replaceAll(RegExp(r'^/+|/+$'), ''); + if (stripped.isEmpty) return title; + final last = stripped.split('/').last; + return last.isEmpty ? title : last; + } + + /// The files search provider exposes the numeric Nextcloud file id either + /// as an attribute, or implicitly in the `?openfile=` query parameter + /// of [resourceUrl]. Parses both into an [int] when available — used to + /// hit the preview endpoint directly. + int? _extractFileId() { + final attr = attributes?['fileId']; + if (attr is int) return attr; + if (attr is String) { + final parsed = int.tryParse(attr); + if (parsed != null) return parsed; + } + final url = resourceUrl; + if (url != null) { + final raw = Uri.tryParse(url)?.queryParameters['openfile']; + if (raw != null) return int.tryParse(raw); + } + return null; + } + CacheableFile? toCacheable() { final path = webdavPath; if (path == null) return null; - return CacheableFile(path: path, isDirectory: isDirectory, name: title); + final fileId = _extractFileId(); + return CacheableFile( + path: path, + isDirectory: isDirectory, + name: _nameFromPath(path), + fileId: fileId, + // Search provider responses don't carry `nc:has-preview`. Probe + // optimistically when a fileId is available — the preview endpoint + // simply returns 404 for unsupported formats, which the file-leading + // widget already falls back from to the typed icon. + hasPreview: (!isDirectory && fileId != null) ? true : null, + ); } } diff --git a/lib/view/pages/files/data/file_type_icon.dart b/lib/view/pages/files/data/file_type_icon.dart new file mode 100644 index 0000000..ddb3b80 --- /dev/null +++ b/lib/view/pages/files/data/file_type_icon.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; + +/// Best-effort mapping from a file's MIME type / extension to a Material +/// icon that visually hints at its kind. Always returns a sensible fallback. +IconData iconForFile(CacheableFile file) { + if (file.isDirectory) return Icons.folder; + final mime = file.mimeType?.toLowerCase(); + if (mime != null && mime.isNotEmpty) { + final byMime = _iconForMime(mime); + if (byMime != null) return byMime; + } + final ext = _extensionOf(file.name); + if (ext != null) { + final byExt = _extensionIcons[ext]; + if (byExt != null) return byExt; + } + return Icons.insert_drive_file_outlined; +} + +String? _extensionOf(String name) { + final dot = name.lastIndexOf('.'); + if (dot <= 0 || dot == name.length - 1) return null; + return name.substring(dot + 1).toLowerCase(); +} + +IconData? _iconForMime(String mime) { + // Major media types first — these cover the long tail of variants. + if (mime.startsWith('image/')) return Icons.image_outlined; + if (mime.startsWith('video/')) return Icons.movie_outlined; + if (mime.startsWith('audio/')) return Icons.audiotrack; + if (mime.startsWith('font/')) return Icons.font_download_outlined; + if (mime.startsWith('text/x-') || + mime.startsWith('text/javascript') || + mime.startsWith('text/css') || + mime.startsWith('text/html')) { + return Icons.code; + } + if (mime.startsWith('text/')) return Icons.article_outlined; + + // Specific application/* types. + const map = { + 'application/pdf': Icons.picture_as_pdf_outlined, + + // Word processing + 'application/msword': Icons.description_outlined, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + Icons.description_outlined, + 'application/vnd.oasis.opendocument.text': Icons.description_outlined, + 'application/rtf': Icons.description_outlined, + + // Spreadsheets + 'application/vnd.ms-excel': Icons.table_chart_outlined, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + Icons.table_chart_outlined, + 'application/vnd.oasis.opendocument.spreadsheet': + Icons.table_chart_outlined, + 'application/vnd.ms-excel.sheet.macroenabled.12': + Icons.table_chart_outlined, + + // Presentations + 'application/vnd.ms-powerpoint': Icons.slideshow_outlined, + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': + Icons.slideshow_outlined, + 'application/vnd.oasis.opendocument.presentation': Icons.slideshow_outlined, + + // Archives + 'application/zip': Icons.folder_zip_outlined, + 'application/x-zip-compressed': Icons.folder_zip_outlined, + 'application/x-rar-compressed': Icons.folder_zip_outlined, + 'application/vnd.rar': Icons.folder_zip_outlined, + 'application/x-7z-compressed': Icons.folder_zip_outlined, + 'application/x-tar': Icons.folder_zip_outlined, + 'application/gzip': Icons.folder_zip_outlined, + 'application/x-bzip2': Icons.folder_zip_outlined, + 'application/x-xz': Icons.folder_zip_outlined, + 'application/zstd': Icons.folder_zip_outlined, + + // Code / structured data + 'application/json': Icons.code, + 'application/ld+json': Icons.code, + 'application/xml': Icons.code, + 'application/x-yaml': Icons.code, + 'application/javascript': Icons.code, + 'application/x-sh': Icons.terminal, + + // Calendar / contacts + 'text/calendar': Icons.calendar_month_outlined, + 'text/vcard': Icons.contact_page_outlined, + + // E-books + 'application/epub+zip': Icons.menu_book_outlined, + 'application/x-mobipocket-ebook': Icons.menu_book_outlined, + + // Executables / installers + 'application/x-msdownload': Icons.terminal, + 'application/x-msi': Icons.terminal, + 'application/x-apple-diskimage': Icons.album_outlined, + 'application/vnd.android.package-archive': Icons.android_outlined, + 'application/octet-stream': Icons.insert_drive_file_outlined, + + // Databases + 'application/x-sqlite3': Icons.storage_outlined, + 'application/vnd.sqlite3': Icons.storage_outlined, + + // 3D + 'model/gltf-binary': Icons.view_in_ar_outlined, + 'model/gltf+json': Icons.view_in_ar_outlined, + 'model/stl': Icons.view_in_ar_outlined, + 'model/obj': Icons.view_in_ar_outlined, + }; + return map[mime]; +} + +const _extensionIcons = { + // Images + 'jpg': Icons.image_outlined, 'jpeg': Icons.image_outlined, + 'png': Icons.image_outlined, 'gif': Icons.image_outlined, + 'webp': Icons.image_outlined, 'bmp': Icons.image_outlined, + 'tif': Icons.image_outlined, 'tiff': Icons.image_outlined, + 'heic': Icons.image_outlined, 'heif': Icons.image_outlined, + 'avif': Icons.image_outlined, 'ico': Icons.image_outlined, + 'raw': Icons.image_outlined, 'cr2': Icons.image_outlined, + 'nef': Icons.image_outlined, 'arw': Icons.image_outlined, + 'svg': Icons.gesture_outlined, 'eps': Icons.gesture_outlined, + 'ai': Icons.gesture_outlined, + + // Video + 'mp4': Icons.movie_outlined, 'm4v': Icons.movie_outlined, + 'mov': Icons.movie_outlined, 'mkv': Icons.movie_outlined, + 'avi': Icons.movie_outlined, 'webm': Icons.movie_outlined, + 'flv': Icons.movie_outlined, 'wmv': Icons.movie_outlined, + '3gp': Icons.movie_outlined, 'mpg': Icons.movie_outlined, + 'mpeg': Icons.movie_outlined, 'ogv': Icons.movie_outlined, + + // Audio + 'mp3': Icons.audiotrack, 'm4a': Icons.audiotrack, 'aac': Icons.audiotrack, + 'wav': Icons.audiotrack, 'flac': Icons.audiotrack, 'ogg': Icons.audiotrack, + 'oga': Icons.audiotrack, 'opus': Icons.audiotrack, 'wma': Icons.audiotrack, + 'aiff': Icons.audiotrack, 'aif': Icons.audiotrack, + + // Music notation + 'mscz': Icons.music_note, 'mscx': Icons.music_note, + 'musicxml': Icons.music_note, 'mxl': Icons.music_note, + 'midi': Icons.music_note, 'mid': Icons.music_note, + + // PDF + 'pdf': Icons.picture_as_pdf_outlined, + + // Word + 'doc': Icons.description_outlined, 'docx': Icons.description_outlined, + 'odt': Icons.description_outlined, 'rtf': Icons.description_outlined, + 'pages': Icons.description_outlined, + + // Spreadsheets + 'xls': Icons.table_chart_outlined, 'xlsx': Icons.table_chart_outlined, + 'xlsm': Icons.table_chart_outlined, 'ods': Icons.table_chart_outlined, + 'csv': Icons.table_chart_outlined, 'tsv': Icons.table_chart_outlined, + 'numbers': Icons.table_chart_outlined, + + // Presentations + 'ppt': Icons.slideshow_outlined, 'pptx': Icons.slideshow_outlined, + 'pps': Icons.slideshow_outlined, 'odp': Icons.slideshow_outlined, + 'key': Icons.slideshow_outlined, + + // Plain text / notes + 'txt': Icons.article_outlined, 'md': Icons.article_outlined, + 'markdown': Icons.article_outlined, 'log': Icons.article_outlined, + 'rst': Icons.article_outlined, + + // Code + 'html': Icons.code, 'htm': Icons.code, 'xhtml': Icons.code, + 'css': Icons.code, 'scss': Icons.code, 'sass': Icons.code, 'less': Icons.code, + 'js': Icons.code, 'mjs': Icons.code, 'cjs': Icons.code, + 'ts': Icons.code, 'jsx': Icons.code, 'tsx': Icons.code, + 'dart': Icons.code, 'java': Icons.code, 'kt': Icons.code, 'kts': Icons.code, + 'py': Icons.code, 'rb': Icons.code, 'go': Icons.code, 'rs': Icons.code, + 'c': Icons.code, 'cpp': Icons.code, 'cc': Icons.code, 'cxx': Icons.code, + 'h': Icons.code, 'hpp': Icons.code, 'cs': Icons.code, 'm': Icons.code, + 'mm': Icons.code, 'php': Icons.code, 'pl': Icons.code, 'lua': Icons.code, + 'r': Icons.code, 'swift': Icons.code, 'scala': Icons.code, 'groovy': Icons.code, + 'sql': Icons.code, 'graphql': Icons.code, 'gql': Icons.code, + 'json': Icons.code, 'json5': Icons.code, 'xml': Icons.code, + 'yaml': Icons.code, 'yml': Icons.code, + + // Shell + 'sh': Icons.terminal, 'bash': Icons.terminal, 'zsh': Icons.terminal, + 'fish': Icons.terminal, 'ps1': Icons.terminal, 'bat': Icons.terminal, + 'cmd': Icons.terminal, + + // Archives + 'zip': Icons.folder_zip_outlined, 'rar': Icons.folder_zip_outlined, + '7z': Icons.folder_zip_outlined, 'tar': Icons.folder_zip_outlined, + 'gz': Icons.folder_zip_outlined, 'bz2': Icons.folder_zip_outlined, + 'xz': Icons.folder_zip_outlined, 'tgz': Icons.folder_zip_outlined, + 'zst': Icons.folder_zip_outlined, + + // Config / settings (the "gear" cases) + 'ini': Icons.settings_outlined, 'cfg': Icons.settings_outlined, + 'conf': Icons.settings_outlined, 'env': Icons.settings_outlined, + 'toml': Icons.settings_outlined, 'plist': Icons.settings_outlined, + 'properties': Icons.settings_outlined, + 'editorconfig': Icons.settings_outlined, + 'gitignore': Icons.settings_outlined, + 'gitattributes': Icons.settings_outlined, + 'dockerignore': Icons.settings_outlined, + 'dockerfile': Icons.settings_outlined, + + // Fonts + 'ttf': Icons.font_download_outlined, 'otf': Icons.font_download_outlined, + 'woff': Icons.font_download_outlined, 'woff2': Icons.font_download_outlined, + 'eot': Icons.font_download_outlined, + + // Calendar / contacts + 'ics': Icons.calendar_month_outlined, 'ical': Icons.calendar_month_outlined, + 'vcf': Icons.contact_page_outlined, 'vcard': Icons.contact_page_outlined, + + // E-books + 'epub': Icons.menu_book_outlined, 'mobi': Icons.menu_book_outlined, + 'azw': Icons.menu_book_outlined, 'azw3': Icons.menu_book_outlined, + + // 3D + 'stl': Icons.view_in_ar_outlined, 'obj': Icons.view_in_ar_outlined, + 'fbx': Icons.view_in_ar_outlined, 'blend': Icons.view_in_ar_outlined, + 'glb': Icons.view_in_ar_outlined, 'gltf': Icons.view_in_ar_outlined, + '3ds': Icons.view_in_ar_outlined, + + // Executables / packages + 'exe': Icons.terminal, 'msi': Icons.terminal, + 'app': Icons.terminal, 'deb': Icons.terminal, 'rpm': Icons.terminal, + 'apk': Icons.android_outlined, 'ipa': Icons.terminal, + 'appimage': Icons.terminal, + + // Disc images + 'iso': Icons.album_outlined, 'img': Icons.album_outlined, + 'dmg': Icons.album_outlined, + + // Databases + 'db': Icons.storage_outlined, 'sqlite': Icons.storage_outlined, + 'sqlite3': Icons.storage_outlined, +}; diff --git a/lib/view/pages/files/widgets/file_details_sheet.dart b/lib/view/pages/files/widgets/file_details_sheet.dart index e2dee4b..5b59fa7 100644 --- a/lib/view/pages/files/widgets/file_details_sheet.dart +++ b/lib/view/pages/files/widgets/file_details_sheet.dart @@ -5,6 +5,7 @@ import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.d import '../../../../extensions/date_time.dart'; import '../../../../utils/clipboard_helper.dart'; import '../../../../widget/details_bottom_sheet.dart'; +import 'file_leading.dart'; /// Shows a modal bottom sheet with technical metadata about a single file or /// folder: full path, MIME type, size, timestamps, ETag. @@ -12,10 +13,7 @@ void showFileDetailsSheet(BuildContext context, CacheableFile file) { showDetailsBottomSheet( context, header: ListTile( - leading: Icon( - file.isDirectory ? Icons.folder : Icons.description_outlined, - size: 32, - ), + leading: FileLeading(file: file, size: 40), title: Text( file.name, style: const TextStyle(fontWeight: FontWeight.bold), diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index da3f107..6cb91b2 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -16,6 +16,7 @@ import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/info_dialog.dart'; import '../../talk/widgets/highlighted_linkify.dart'; import 'file_details_sheet.dart'; +import 'file_leading.dart'; class FileElement extends StatefulWidget { final CacheableFile file; @@ -372,9 +373,7 @@ class _FileElementState extends State { @override Widget build(BuildContext context) => ListTile( - leading: CenteredLeading( - Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined), - ), + leading: CenteredLeading(FileLeading(file: widget.file)), title: _title(context), subtitle: _subtitle(), trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null), diff --git a/lib/view/pages/files/widgets/file_leading.dart b/lib/view/pages/files/widgets/file_leading.dart new file mode 100644 index 0000000..82619d2 --- /dev/null +++ b/lib/view/pages/files/widgets/file_leading.dart @@ -0,0 +1,51 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../../model/account_data.dart'; +import '../../../../model/endpoint_data.dart'; +import '../data/file_type_icon.dart'; + +/// Leading slot for a file row: shows the Nextcloud thumbnail when the +/// server can render one (`nc:has-preview`), otherwise a typed file icon. +/// Always reserves the same square so rows stay aligned regardless of +/// whether a preview is present. +class FileLeading extends StatelessWidget { + final CacheableFile file; + + /// Edge length of the rendered square. Defaults to the regular row size; + /// use a larger value (e.g. in detail sheets) where appropriate. + final double size; + + const FileLeading({required this.file, this.size = 28, super.key}); + + @override + Widget build(BuildContext context) { + final icon = Icon(iconForFile(file), size: size); + final fileId = file.fileId; + return SizedBox( + width: size, + height: size, + child: (file.isDirectory || file.hasPreview != true || fileId == null) + ? Center(child: icon) + : ClipRRect( + borderRadius: BorderRadius.circular(4), + child: CachedNetworkImage( + imageUrl: + 'https://${EndpointData().nextcloud().full()}' + '/index.php/core/preview' + '?fileId=$fileId&x=128&y=128&a=0', + httpHeaders: AccountData().authHeaders(), + fit: BoxFit.cover, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + errorListener: (_) {}, + // Icon doubles as the loading placeholder so the list doesn't + // pop a spinner per row while thumbnails stream in. + placeholder: (_, _) => Center(child: icon), + errorWidget: (_, _, _) => Center(child: icon), + ), + ), + ); + } +} diff --git a/lib/view/pages/share_intent/share_folder_picker.dart b/lib/view/pages/share_intent/share_folder_picker.dart index 914f156..48cd83f 100644 --- a/lib/view/pages/share_intent/share_folder_picker.dart +++ b/lib/view/pages/share_intent/share_folder_picker.dart @@ -19,6 +19,7 @@ import '../../../widget/placeholder_view.dart'; import '../files/data/sort_options.dart'; import '../files/files_upload_dialog.dart'; import '../files/widgets/add_file_menu.dart'; +import '../files/widgets/file_leading.dart'; import '../files/widgets/files_sort_actions.dart'; typedef _FolderConfirmedCallback = @@ -181,7 +182,7 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> { final entry = entries[i]; if (entry.isDirectory) { return ListTile( - leading: const Icon(Icons.folder_outlined), + leading: FileLeading(file: entry), title: Text(entry.name), trailing: const Icon(Icons.chevron_right), onTap: () => _enter(bloc, state.currentPath, entry.name), @@ -189,7 +190,7 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> { } return ListTile( enabled: false, - leading: const Icon(Icons.description_outlined), + leading: FileLeading(file: entry), title: Text(entry.name), ); },