implemented Nextcloud file previews for unknown file types using fileId and has-preview flags, updated file models, and refined manual refresh logic.

This commit is contained in:
2026-05-13 19:44:26 +02:00
parent 843686358f
commit 092f9b622b
6 changed files with 174 additions and 41 deletions
@@ -15,6 +15,16 @@ class CacheableFile {
DateTime? modifiedAt; DateTime? modifiedAt;
String? sort; 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({ CacheableFile({
required this.path, required this.path,
required this.isDirectory, required this.isDirectory,
@@ -24,6 +34,8 @@ class CacheableFile {
this.eTag, this.eTag,
this.createdAt, this.createdAt,
this.modifiedAt, this.modifiedAt,
this.fileId,
this.hasPreview,
}); });
CacheableFile.fromDavFile(WebDavFile file) { CacheableFile.fromDavFile(WebDavFile file) {
@@ -35,6 +47,8 @@ class CacheableFile {
eTag = file.etag; eTag = file.etag;
createdAt = file.createdDate; createdAt = file.createdDate;
modifiedAt = file.lastModified; modifiedAt = file.lastModified;
fileId = int.tryParse(file.fileId ?? '');
hasPreview = file.hasPreview;
} }
factory CacheableFile.fromJson(Map<String, dynamic> json) => factory CacheableFile.fromJson(Map<String, dynamic> json) =>
@@ -20,6 +20,8 @@ CacheableFile _$CacheableFileFromJson(Map<String, dynamic> json) =>
modifiedAt: json['modifiedAt'] == null modifiedAt: json['modifiedAt'] == null
? null ? null
: DateTime.parse(json['modifiedAt'] as String), : DateTime.parse(json['modifiedAt'] as String),
fileId: (json['fileId'] as num?)?.toInt(),
hasPreview: json['hasPreview'] as bool?,
)..sort = json['sort'] as String?; )..sort = json['sort'] as String?;
Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) => Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
@@ -33,4 +35,6 @@ Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
'createdAt': instance.createdAt?.toIso8601String(), 'createdAt': instance.createdAt?.toIso8601String(),
'modifiedAt': instance.modifiedAt?.toIso8601String(), 'modifiedAt': instance.modifiedAt?.toIso8601String(),
'sort': instance.sort, 'sort': instance.sort,
'fileId': instance.fileId,
'hasPreview': instance.hasPreview,
}; };
@@ -25,8 +25,23 @@ class ListFiles extends WebdavApi<ListFilesParams> {
Future<ListFilesResponse> run() async { Future<ListFilesResponse> run() async {
final webdav = await WebdavApi.webdav; final webdav = await WebdavApi.webdav;
final timeout = _isRoot ? _rootTimeout : _subfolderTimeout; 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 = final davFiles =
(await webdav.propfind(PathUri.parse(params.path)).timeout(timeout)) (await webdav
.propfind(PathUri.parse(params.path), prop: prop)
.timeout(timeout))
.toWebDavFiles(); .toWebDavFiles();
final files = davFiles.map(CacheableFile.fromDavFile).toSet(); final files = davFiles.map(CacheableFile.fromDavFile).toSet();
+25 -5
View File
@@ -8,13 +8,33 @@ class RemoteFileRef {
final String path; final String path;
final String name; 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 /// Caller must verify `file.path != null` first — Talk message parameters
/// without a path (system events, mentions, polls) are not file refs. /// without a path (system events, mentions, polls) are not file refs.
factory RemoteFileRef.fromTalk(RichObjectString file) => factory RemoteFileRef.fromTalk(RichObjectString file) => RemoteFileRef(
RemoteFileRef(path: file.path!, name: file.name); path: file.path!,
name: file.name,
fileId: int.tryParse(file.id),
);
factory RemoteFileRef.fromCacheable(CacheableFile file) => factory RemoteFileRef.fromCacheable(CacheableFile file) => RemoteFileRef(
RemoteFileRef(path: file.path, name: file.name); path: file.path,
name: file.name,
fileId: file.fileId,
hasPreview: file.hasPreview,
);
} }
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/errors/error_mapper.dart';
@@ -42,6 +44,16 @@ class FilesBloc
await _query(path, renew: true); 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<void> setPath(List<String> path) async { Future<void> setPath(List<String> path) async {
add(Emit((s) => s.copyWith(currentPath: path, listing: null))); add(Emit((s) => s.copyWith(currentPath: path, listing: null)));
add(RefetchStarted<FilesState>()); add(RefetchStarted<FilesState>());
+103 -35
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:chewie/chewie.dart'; import 'package:chewie/chewie.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.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:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import '../model/account_data.dart';
import '../model/endpoint_data.dart';
import '../routing/app_routes.dart'; import '../routing/app_routes.dart';
import '../share_intent/remote_file_ref.dart'; import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart';
@@ -22,6 +25,21 @@ import 'centered_leading.dart';
import 'info_dialog.dart'; import 'info_dialog.dart';
import 'share_position_origin.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 { class FileViewer extends StatefulWidget {
final String path; final String path;
final bool openExternal; final bool openExternal;
@@ -482,52 +500,58 @@ class _FileViewerState extends State<FileViewer> {
), ),
); );
Widget _buildUnknownView() { Widget _buildUnknownView() => Scaffold(
appBar: _appbar(showActionsMenu: false),
body: _buildUnknownPlaceholder(),
);
Widget _buildUnknownPlaceholder() {
final theme = Theme.of(context); final theme = Theme.of(context);
final descriptors = _availableActions(); final descriptors = _availableActions();
return Scaffold( final remote = widget.remoteFile;
appBar: _appbar(showActionsMenu: false), final hasPreview = remote?.hasPreview == true;
body: ListView( return ListView(
padding: const EdgeInsets.symmetric(vertical: 24), padding: const EdgeInsets.symmetric(vertical: 24),
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column( child: Column(
children: [ children: [
const Icon(Icons.insert_drive_file_outlined, size: 60), _UnknownPreviewHeader(remoteFile: remote),
const SizedBox(height: 16), const SizedBox(height: 16),
if (!hasPreview) ...[
Text( Text(
'Vorschau nicht verfügbar', 'Vorschau nicht verfügbar',
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 6), 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( const SizedBox(height: 24),
(d) => ListTile( ...descriptors.map(
leading: CenteredLeading(Icon(d.icon)), (d) => ListTile(
title: Text(d.label), leading: CenteredLeading(Icon(d.icon)),
onTap: () => _handleAction(d.action), title: Text(d.label),
), onTap: () => _handleAction(d.action),
), ),
], ),
), ],
); );
} }
@@ -583,6 +607,50 @@ class _TextPayload {
const _TextPayload({required this.content, required this.truncated}); 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 { class _MediaPlayer extends StatefulWidget {
final String path; final String path;
final bool isAudio; final bool isAudio;