implemented file thumbnails and enhanced file type icons, added reusable FileLeading widget, and updated search to support previews
This commit is contained in:
@@ -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=<id>` 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = <String, IconData>{
|
||||
'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 = <String, IconData>{
|
||||
// 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,
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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<FileElement> {
|
||||
|
||||
@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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user