diff --git a/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart index bf23d1e..e8c1130 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart @@ -15,6 +15,16 @@ class CacheableFile { DateTime? modifiedAt; String? sort; + /// Nextcloud's instance-local file id (`oc:fileid`). Used to address the + /// preview API by id, which is more reliable than the path-based variant + /// on some server configurations. + int? fileId; + + /// Server's answer to "can I render a thumbnail for this file?" + /// (`nc:has-preview`). Lets the file viewer skip the placeholder text + /// when a preview is going to load anyway. + bool? hasPreview; + CacheableFile({ required this.path, required this.isDirectory, @@ -24,6 +34,8 @@ class CacheableFile { this.eTag, this.createdAt, this.modifiedAt, + this.fileId, + this.hasPreview, }); CacheableFile.fromDavFile(WebDavFile file) { @@ -35,6 +47,8 @@ class CacheableFile { eTag = file.etag; createdAt = file.createdDate; modifiedAt = file.lastModified; + fileId = int.tryParse(file.fileId ?? ''); + hasPreview = file.hasPreview; } factory CacheableFile.fromJson(Map json) => diff --git a/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.g.dart b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.g.dart index 4e3407d..63bc94f 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.g.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.g.dart @@ -20,6 +20,8 @@ CacheableFile _$CacheableFileFromJson(Map json) => modifiedAt: json['modifiedAt'] == null ? null : DateTime.parse(json['modifiedAt'] as String), + fileId: (json['fileId'] as num?)?.toInt(), + hasPreview: json['hasPreview'] as bool?, )..sort = json['sort'] as String?; Map _$CacheableFileToJson(CacheableFile instance) => @@ -33,4 +35,6 @@ Map _$CacheableFileToJson(CacheableFile instance) => 'createdAt': instance.createdAt?.toIso8601String(), 'modifiedAt': instance.modifiedAt?.toIso8601String(), 'sort': instance.sort, + 'fileId': instance.fileId, + 'hasPreview': instance.hasPreview, }; diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart index d9b9745..424a21a 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart @@ -25,8 +25,23 @@ class ListFiles extends WebdavApi { Future run() async { final webdav = await WebdavApi.webdav; final timeout = _isRoot ? _rootTimeout : _subfolderTimeout; + // Explicit prop list — without it the server omits OC-namespaced + // properties like oc:fileid, which the preview endpoint relies on. + final prop = WebDavPropWithoutValues.fromBools( + davgetlastmodified: true, + davgetetag: true, + davgetcontenttype: true, + davgetcontentlength: true, + davresourcetype: true, + ocfileid: true, + ocsize: true, + nccreationtime: true, + nchaspreview: true, + ); final davFiles = - (await webdav.propfind(PathUri.parse(params.path)).timeout(timeout)) + (await webdav + .propfind(PathUri.parse(params.path), prop: prop) + .timeout(timeout)) .toWebDavFiles(); final files = davFiles.map(CacheableFile.fromDavFile).toSet(); diff --git a/lib/share_intent/remote_file_ref.dart b/lib/share_intent/remote_file_ref.dart index 909c410..5550565 100644 --- a/lib/share_intent/remote_file_ref.dart +++ b/lib/share_intent/remote_file_ref.dart @@ -8,13 +8,33 @@ class RemoteFileRef { final String path; final String name; - const RemoteFileRef({required this.path, required this.name}); + /// Nextcloud's instance-local file id, when known. Preferred over [path] + /// for hitting the preview API — the path-based variant is unreliable on + /// some server configurations. + final int? fileId; + + /// Server's `nc:has-preview` flag when known. null = unknown. + final bool? hasPreview; + + const RemoteFileRef({ + required this.path, + required this.name, + this.fileId, + this.hasPreview, + }); /// Caller must verify `file.path != null` first — Talk message parameters /// without a path (system events, mentions, polls) are not file refs. - factory RemoteFileRef.fromTalk(RichObjectString file) => - RemoteFileRef(path: file.path!, name: file.name); + factory RemoteFileRef.fromTalk(RichObjectString file) => RemoteFileRef( + path: file.path!, + name: file.name, + fileId: int.tryParse(file.id), + ); - factory RemoteFileRef.fromCacheable(CacheableFile file) => - RemoteFileRef(path: file.path, name: file.name); + factory RemoteFileRef.fromCacheable(CacheableFile file) => RemoteFileRef( + path: file.path, + name: file.name, + fileId: file.fileId, + hasPreview: file.hasPreview, + ); } diff --git a/lib/state/app/modules/files/bloc/files_bloc.dart b/lib/state/app/modules/files/bloc/files_bloc.dart index c7e41fd..a0dbb1d 100644 --- a/lib/state/app/modules/files/bloc/files_bloc.dart +++ b/lib/state/app/modules/files/bloc/files_bloc.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; import '../../../../../api/errors/error_mapper.dart'; @@ -42,6 +44,16 @@ class FilesBloc await _query(path, renew: true); } + /// LoadableState.reFetch (used by the pull-to-refresh indicator and the + /// error-screen retry button) routes through here. The inherited retry() + /// goes via gatherData() which respects the cache TTL — for an explicit + /// user-initiated reload we must bypass it, otherwise the root listing + /// silently returns its day-old cached payload without hitting the server. + @override + void retry() { + unawaited(refresh()); + } + Future setPath(List path) async { add(Emit((s) => s.copyWith(currentPath: path, listing: null))); add(RefetchStarted()); diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 8179e21..27f4e7e 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:chewie/chewie.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -14,6 +15,8 @@ import 'package:share_plus/share_plus.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import 'package:video_player/video_player.dart'; +import '../model/account_data.dart'; +import '../model/endpoint_data.dart'; import '../routing/app_routes.dart'; import '../share_intent/remote_file_ref.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart'; @@ -22,6 +25,21 @@ import 'centered_leading.dart'; import 'info_dialog.dart'; import 'share_position_origin.dart'; +/// Nextcloud's `/index.php/core/preview` endpoint — returns a rasterized +/// thumbnail for any file the server has a preview provider for (images, +/// PDFs with the right backend, Office in some setups). Falls back to an +/// HTTP 404 when no preview is available, which lets [CachedNetworkImage] +/// trigger its `errorWidget`. Prefers `fileId` because the path variant +/// is unreliable on some server configurations. +String _ncPreviewUrl(RemoteFileRef remote, {int width = 1024}) { + final host = EndpointData().nextcloud().full(); + final id = remote.fileId; + final selector = id != null + ? 'fileId=$id' + : 'file=${Uri.encodeQueryComponent(remote.path)}'; + return 'https://$host/index.php/core/preview?$selector&x=$width&y=-1&a=1'; +} + class FileViewer extends StatefulWidget { final String path; final bool openExternal; @@ -482,52 +500,58 @@ class _FileViewerState extends State { ), ); - Widget _buildUnknownView() { + Widget _buildUnknownView() => Scaffold( + appBar: _appbar(showActionsMenu: false), + body: _buildUnknownPlaceholder(), + ); + + Widget _buildUnknownPlaceholder() { final theme = Theme.of(context); final descriptors = _availableActions(); - return Scaffold( - appBar: _appbar(showActionsMenu: false), - body: ListView( - padding: const EdgeInsets.symmetric(vertical: 24), - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - children: [ - const Icon(Icons.insert_drive_file_outlined, size: 60), - const SizedBox(height: 16), + final remote = widget.remoteFile; + final hasPreview = remote?.hasPreview == true; + return ListView( + padding: const EdgeInsets.symmetric(vertical: 24), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + _UnknownPreviewHeader(remoteFile: remote), + const SizedBox(height: 16), + if (!hasPreview) ...[ Text( 'Vorschau nicht verfügbar', style: theme.textTheme.titleMedium, textAlign: TextAlign.center, ), const SizedBox(height: 6), - Text( - widget.path.split('/').last, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - 'Wähle eine Aktion, um mit der Datei weiterzuarbeiten.', - style: theme.textTheme.bodySmall, - textAlign: TextAlign.center, - ), ], - ), + Text( + widget.path.split('/').last, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Wähle eine Aktion, um mit der Datei weiterzuarbeiten.', + style: theme.textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], ), - const SizedBox(height: 24), - ...descriptors.map( - (d) => ListTile( - leading: CenteredLeading(Icon(d.icon)), - title: Text(d.label), - onTap: () => _handleAction(d.action), - ), + ), + const SizedBox(height: 24), + ...descriptors.map( + (d) => ListTile( + leading: CenteredLeading(Icon(d.icon)), + title: Text(d.label), + onTap: () => _handleAction(d.action), ), - ], - ), + ), + ], ); } @@ -583,6 +607,50 @@ class _TextPayload { const _TextPayload({required this.content, required this.truncated}); } +/// Header for the "Vorschau nicht verfügbar" screen: tries to fetch the +/// Nextcloud thumbnail and shows it mid-sized if available, otherwise +/// falls back to the generic file icon. +class _UnknownPreviewHeader extends StatelessWidget { + final RemoteFileRef? remoteFile; + const _UnknownPreviewHeader({required this.remoteFile}); + + static const double _previewSize = 180; + + Widget _fallbackIcon() => + const Icon(Icons.insert_drive_file_outlined, size: 60); + + @override + Widget build(BuildContext context) { + final remote = remoteFile; + // Skip the probe outright when the server already told us there is no + // preview — saves an HTTP request that would 404 anyway. + if (remote == null || remote.hasPreview == false) return _fallbackIcon(); + return SizedBox( + width: _previewSize, + height: _previewSize, + child: CachedNetworkImage( + httpHeaders: AccountData().authHeaders(), + imageUrl: _ncPreviewUrl(remote, width: 360), + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + errorListener: (_) {}, + placeholder: (_, _) => + const Center(child: AppProgressIndicator.large()), + errorWidget: (_, _, _) => Center(child: _fallbackIcon()), + imageBuilder: (_, imageProvider) => ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image( + image: imageProvider, + fit: BoxFit.contain, + width: _previewSize, + height: _previewSize, + ), + ), + ), + ); + } +} + class _MediaPlayer extends StatefulWidget { final String path; final bool isAudio;