split file_viewer into focused sub-widgets
This commit is contained in:
+20
-479
@@ -3,8 +3,6 @@ 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:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -12,34 +10,20 @@ import 'package:flutter_svg/flutter_svg.dart';
|
|||||||
import 'package:open_filex/open_filex.dart';
|
import 'package:open_filex/open_filex.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
import 'package:photo_view/photo_view.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
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 '../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';
|
||||||
import 'app_progress_indicator.dart';
|
import 'app_progress_indicator.dart';
|
||||||
import 'centered_leading.dart';
|
import 'centered_leading.dart';
|
||||||
|
import 'file_viewer/code_line.dart';
|
||||||
|
import 'file_viewer/deferred_pdf_viewer.dart';
|
||||||
|
import 'file_viewer/file_kind.dart';
|
||||||
|
import 'file_viewer/media_player.dart';
|
||||||
|
import 'file_viewer/unknown_preview_block.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;
|
||||||
@@ -61,133 +45,12 @@ class FileViewer extends StatefulWidget {
|
|||||||
|
|
||||||
enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
|
enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
|
||||||
|
|
||||||
enum _FileKind { image, svg, pdf, text, video, audio, unknown }
|
|
||||||
|
|
||||||
const Set<String> _imageExtensions = {
|
|
||||||
'png',
|
|
||||||
'jpg',
|
|
||||||
'jpeg',
|
|
||||||
'webp',
|
|
||||||
'gif',
|
|
||||||
'bmp',
|
|
||||||
'wbmp',
|
|
||||||
};
|
|
||||||
|
|
||||||
const Set<String> _videoExtensions = {
|
|
||||||
'mp4',
|
|
||||||
'm4v',
|
|
||||||
'mov',
|
|
||||||
'webm',
|
|
||||||
'mkv',
|
|
||||||
'3gp',
|
|
||||||
};
|
|
||||||
|
|
||||||
/// ogg/opus/flac are Android-only; iOS init errors fall through to the
|
|
||||||
/// "format not supported" message.
|
|
||||||
const Set<String> _audioExtensions = {
|
|
||||||
'mp3',
|
|
||||||
'm4a',
|
|
||||||
'aac',
|
|
||||||
'wav',
|
|
||||||
'flac',
|
|
||||||
'ogg',
|
|
||||||
'oga',
|
|
||||||
'opus',
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Unknown extensions still get a content sniff via [_looksLikeText].
|
|
||||||
const Set<String> _textExtensions = {
|
|
||||||
'txt', 'md', 'markdown', 'rst', 'log',
|
|
||||||
'json', 'json5', 'xml', 'yaml', 'yml', 'toml',
|
|
||||||
'csv', 'tsv', 'tab',
|
|
||||||
'ini', 'conf', 'cfg', 'env', 'properties',
|
|
||||||
'html', 'htm', 'xhtml',
|
|
||||||
'css', 'scss', 'sass', 'less',
|
|
||||||
'js', 'mjs', 'cjs', 'ts', 'jsx', 'tsx',
|
|
||||||
'dart', 'java', 'kt', 'kts', 'groovy', 'scala', 'swift',
|
|
||||||
'py', 'rb', 'pl', 'lua', 'r',
|
|
||||||
'go', 'rs', 'zig',
|
|
||||||
'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'cs', 'm', 'mm',
|
|
||||||
'php', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
|
|
||||||
'sql', 'graphql', 'gql',
|
|
||||||
'gitignore', 'gitattributes', 'editorconfig', 'dockerignore',
|
|
||||||
'dockerfile', 'makefile', 'cmake',
|
|
||||||
'tex', 'bib',
|
|
||||||
'srt', 'vtt',
|
|
||||||
};
|
|
||||||
|
|
||||||
/// 8 KB sniff: NUL bytes or non-UTF-8 sequences disqualify.
|
|
||||||
Future<bool> _looksLikeText(String path) async {
|
|
||||||
final file = File(path);
|
|
||||||
RandomAccessFile? raf;
|
|
||||||
try {
|
|
||||||
final length = await file.length();
|
|
||||||
if (length == 0) return true;
|
|
||||||
raf = await file.open();
|
|
||||||
final sample = await raf.read(min(length, 8192));
|
|
||||||
if (sample.contains(0)) return false;
|
|
||||||
utf8.decode(sample);
|
|
||||||
return true;
|
|
||||||
} on Object {
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
await raf?.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SfPdfViewer asserts on `localToGlobal` if mounted during the page-push
|
|
||||||
/// animation. Defer until the route enter animation completes.
|
|
||||||
class _DeferredPdfViewer extends StatefulWidget {
|
|
||||||
const _DeferredPdfViewer({required this.path});
|
|
||||||
final String path;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_DeferredPdfViewer> createState() => _DeferredPdfViewerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DeferredPdfViewerState extends State<_DeferredPdfViewer> {
|
|
||||||
bool _ready = false;
|
|
||||||
Animation<double>? _routeAnimation;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeDependencies() {
|
|
||||||
super.didChangeDependencies();
|
|
||||||
if (_ready || _routeAnimation != null) return;
|
|
||||||
final animation = ModalRoute.of(context)?.animation;
|
|
||||||
if (animation == null || animation.isCompleted) {
|
|
||||||
_ready = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_routeAnimation = animation..addStatusListener(_onAnimationStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onAnimationStatus(AnimationStatus status) {
|
|
||||||
if (status == AnimationStatus.completed && mounted) {
|
|
||||||
setState(() => _ready = true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_routeAnimation?.removeStatusListener(_onAnimationStatus);
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (!_ready) {
|
|
||||||
return const Center(child: AppProgressIndicator.large());
|
|
||||||
}
|
|
||||||
return SfPdfViewer.file(File(widget.path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FileViewerState extends State<FileViewer> {
|
class _FileViewerState extends State<FileViewer> {
|
||||||
final PhotoViewController photoViewController = PhotoViewController();
|
final PhotoViewController photoViewController = PhotoViewController();
|
||||||
|
|
||||||
late SettingsCubit settings = context.read<SettingsCubit>();
|
late SettingsCubit settings = context.read<SettingsCubit>();
|
||||||
late bool openExternal;
|
late bool openExternal;
|
||||||
Future<_FileKind>? _fileKind;
|
Future<FileKind>? _fileKind;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -200,7 +63,7 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
(_) => _openExternallyAndPop(),
|
(_) => _openExternallyAndPop(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
_fileKind = _detectKind();
|
_fileKind = detectFileKind(widget.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,18 +82,6 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<_FileKind> _detectKind() async {
|
|
||||||
final ext = widget.path.split('.').last.toLowerCase();
|
|
||||||
if (_imageExtensions.contains(ext)) return _FileKind.image;
|
|
||||||
if (ext == 'svg') return _FileKind.svg;
|
|
||||||
if (ext == 'pdf') return _FileKind.pdf;
|
|
||||||
if (_videoExtensions.contains(ext)) return _FileKind.video;
|
|
||||||
if (_audioExtensions.contains(ext)) return _FileKind.audio;
|
|
||||||
if (_textExtensions.contains(ext)) return _FileKind.text;
|
|
||||||
if (await _looksLikeText(widget.path)) return _FileKind.text;
|
|
||||||
return _FileKind.unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleAction(FileViewingActions value) async {
|
Future<void> _handleAction(FileViewingActions value) async {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case FileViewingActions.openExternal:
|
case FileViewingActions.openExternal:
|
||||||
@@ -360,7 +211,7 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
body: const Center(child: AppProgressIndicator.large()),
|
body: const Center(child: AppProgressIndicator.large()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return FutureBuilder<_FileKind>(
|
return FutureBuilder<FileKind>(
|
||||||
future: _fileKind,
|
future: _fileKind,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (!snapshot.hasData) {
|
if (!snapshot.hasData) {
|
||||||
@@ -370,19 +221,19 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
switch (snapshot.data!) {
|
switch (snapshot.data!) {
|
||||||
case _FileKind.image:
|
case FileKind.image:
|
||||||
return _buildImageView();
|
return _buildImageView();
|
||||||
case _FileKind.svg:
|
case FileKind.svg:
|
||||||
return _buildSvgView();
|
return _buildSvgView();
|
||||||
case _FileKind.pdf:
|
case FileKind.pdf:
|
||||||
return _buildPdfView();
|
return _buildPdfView();
|
||||||
case _FileKind.video:
|
case FileKind.video:
|
||||||
return _buildVideoView();
|
return _buildVideoView();
|
||||||
case _FileKind.audio:
|
case FileKind.audio:
|
||||||
return _buildAudioView();
|
return _buildAudioView();
|
||||||
case _FileKind.text:
|
case FileKind.text:
|
||||||
return _buildTextView();
|
return _buildTextView();
|
||||||
case _FileKind.unknown:
|
case FileKind.unknown:
|
||||||
return _buildUnknownView();
|
return _buildUnknownView();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -431,17 +282,17 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildPdfView() =>
|
Widget _buildPdfView() =>
|
||||||
Scaffold(appBar: _appbar(), body: _DeferredPdfViewer(path: widget.path));
|
Scaffold(appBar: _appbar(), body: DeferredPdfViewer(path: widget.path));
|
||||||
|
|
||||||
Widget _buildVideoView() => Scaffold(
|
Widget _buildVideoView() => Scaffold(
|
||||||
appBar: _appbar(),
|
appBar: _appbar(),
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: _MediaPlayer(path: widget.path, isAudio: false),
|
body: MediaPlayer(path: widget.path, isAudio: false),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildAudioView() => Scaffold(
|
Widget _buildAudioView() => Scaffold(
|
||||||
appBar: _appbar(),
|
appBar: _appbar(),
|
||||||
body: _MediaPlayer(
|
body: MediaPlayer(
|
||||||
path: widget.path,
|
path: widget.path,
|
||||||
isAudio: true,
|
isAudio: true,
|
||||||
filename: widget.path.split('/').last,
|
filename: widget.path.split('/').last,
|
||||||
@@ -485,7 +336,7 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
),
|
),
|
||||||
SliverList.builder(
|
SliverList.builder(
|
||||||
itemCount: lines.length,
|
itemCount: lines.length,
|
||||||
itemBuilder: (context, i) => _CodeLine(
|
itemBuilder: (context, i) => CodeLine(
|
||||||
number: i + 1,
|
number: i + 1,
|
||||||
text: lines[i],
|
text: lines[i],
|
||||||
gutterWidth: gutterWidth,
|
gutterWidth: gutterWidth,
|
||||||
@@ -515,7 +366,7 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_UnknownPreviewBlock(remoteFile: widget.remoteFile),
|
UnknownPreviewBlock(remoteFile: widget.remoteFile),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
widget.path.split('/').last,
|
widget.path.split('/').last,
|
||||||
@@ -596,313 +447,3 @@ class _TextPayload {
|
|||||||
final bool truncated;
|
final bool truncated;
|
||||||
const _TextPayload({required this.content, required this.truncated});
|
const _TextPayload({required this.content, required this.truncated});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Header block for the "Vorschau nicht verfügbar" screen.
|
|
||||||
///
|
|
||||||
/// Two visual modes — kept layout-equivalent so the screen looks identical
|
|
||||||
/// whether the server already said "no preview" or the probe failed late:
|
|
||||||
/// * **No preview available** (server said no, no remoteFile, or probe
|
|
||||||
/// errored): compact "file icon + 'Vorschau nicht verfügbar' text".
|
|
||||||
/// * **Preview rendering / loaded**: mid-sized thumbnail without text.
|
|
||||||
class _UnknownPreviewBlock extends StatefulWidget {
|
|
||||||
final RemoteFileRef? remoteFile;
|
|
||||||
const _UnknownPreviewBlock({required this.remoteFile});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_UnknownPreviewBlock> createState() => _UnknownPreviewBlockState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _UnknownPreviewBlockState extends State<_UnknownPreviewBlock> {
|
|
||||||
static const double _previewSize = 180;
|
|
||||||
bool _failed = false;
|
|
||||||
|
|
||||||
Widget _compact(ThemeData theme) => Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.insert_drive_file_outlined, size: 60),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Vorschau nicht verfügbar',
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final remote = widget.remoteFile;
|
|
||||||
final canProbe =
|
|
||||||
remote != null &&
|
|
||||||
remote.hasPreview != false &&
|
|
||||||
remote.fileId != null &&
|
|
||||||
!_failed;
|
|
||||||
if (!canProbe) return _compact(theme);
|
|
||||||
return SizedBox(
|
|
||||||
width: _previewSize,
|
|
||||||
height: _previewSize,
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
httpHeaders: AccountData().authHeaders(),
|
|
||||||
imageUrl: _ncPreviewUrl(remote, width: 360),
|
|
||||||
fadeInDuration: Duration.zero,
|
|
||||||
fadeOutDuration: Duration.zero,
|
|
||||||
// Late probe failure: re-render into the compact layout so the
|
|
||||||
// screen doesn't keep a 180×180 box around a tiny icon. Deferred
|
|
||||||
// to the next frame because setState during build is illegal.
|
|
||||||
errorListener: (_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) setState(() => _failed = true);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
placeholder: (_, _) =>
|
|
||||||
const Center(child: AppProgressIndicator.large()),
|
|
||||||
// Briefly empty while the post-frame setState swaps layouts.
|
|
||||||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
|
||||||
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;
|
|
||||||
final String? filename;
|
|
||||||
const _MediaPlayer({
|
|
||||||
required this.path,
|
|
||||||
required this.isAudio,
|
|
||||||
this.filename,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_MediaPlayer> createState() => _MediaPlayerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MediaPlayerState extends State<_MediaPlayer> {
|
|
||||||
VideoPlayerController? _video;
|
|
||||||
ChewieController? _chewie;
|
|
||||||
Object? _initError;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _initialize() async {
|
|
||||||
final controller = VideoPlayerController.file(File(widget.path));
|
|
||||||
try {
|
|
||||||
await controller.initialize();
|
|
||||||
} on Object catch (e) {
|
|
||||||
await controller.dispose();
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _initError = e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!mounted) {
|
|
||||||
await controller.dispose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (widget.isAudio) {
|
|
||||||
controller.addListener(_onAudioTick);
|
|
||||||
setState(() => _video = controller);
|
|
||||||
} else {
|
|
||||||
setState(() {
|
|
||||||
_video = controller;
|
|
||||||
_chewie = ChewieController(
|
|
||||||
videoPlayerController: controller,
|
|
||||||
autoPlay: false,
|
|
||||||
looping: false,
|
|
||||||
allowFullScreen: true,
|
|
||||||
allowPlaybackSpeedChanging: true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onAudioTick() {
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_video?.removeListener(_onAudioTick);
|
|
||||||
_chewie?.dispose();
|
|
||||||
_video?.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (_initError != null) {
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.error_outline, size: 48),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
widget.isAudio
|
|
||||||
? 'Audio kann nicht abgespielt werden'
|
|
||||||
: 'Video kann nicht abgespielt werden',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Format wird auf diesem Gerät nicht unterstützt. Über das Menü kannst du die Datei in einer anderen App öffnen.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (_video == null) {
|
|
||||||
return const Center(child: AppProgressIndicator.large());
|
|
||||||
}
|
|
||||||
if (widget.isAudio) {
|
|
||||||
return _AudioControls(
|
|
||||||
controller: _video!,
|
|
||||||
filename: widget.filename ?? '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Chewie(controller: _chewie!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AudioControls extends StatelessWidget {
|
|
||||||
final VideoPlayerController controller;
|
|
||||||
final String filename;
|
|
||||||
const _AudioControls({required this.controller, required this.filename});
|
|
||||||
|
|
||||||
String _format(Duration d) {
|
|
||||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
|
||||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
|
||||||
if (d.inHours > 0) return '${d.inHours}:$m:$s';
|
|
||||||
return '$m:$s';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final value = controller.value;
|
|
||||||
final duration = value.duration;
|
|
||||||
final position = value.position;
|
|
||||||
final maxMs = duration.inMilliseconds == 0 ? 1 : duration.inMilliseconds;
|
|
||||||
final posMs = position.inMilliseconds.clamp(0, maxMs).toDouble();
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.audiotrack,
|
|
||||||
size: 96,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Text(
|
|
||||||
filename,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
Slider(
|
|
||||||
min: 0,
|
|
||||||
max: maxMs.toDouble(),
|
|
||||||
value: posMs,
|
|
||||||
onChanged: (v) =>
|
|
||||||
controller.seekTo(Duration(milliseconds: v.toInt())),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_format(position),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
_format(duration),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
FloatingActionButton(
|
|
||||||
heroTag: 'audioPlayPause',
|
|
||||||
onPressed: () {
|
|
||||||
if (value.isPlaying) {
|
|
||||||
controller.pause();
|
|
||||||
} else {
|
|
||||||
controller.play();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Icon(value.isPlaying ? Icons.pause : Icons.play_arrow),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CodeLine extends StatelessWidget {
|
|
||||||
final int number;
|
|
||||||
final String text;
|
|
||||||
final double gutterWidth;
|
|
||||||
const _CodeLine({
|
|
||||||
required this.number,
|
|
||||||
required this.text,
|
|
||||||
required this.gutterWidth,
|
|
||||||
});
|
|
||||||
|
|
||||||
static const TextStyle _codeStyle = TextStyle(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 13,
|
|
||||||
height: 1.4,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final isEven = number.isEven;
|
|
||||||
return Container(
|
|
||||||
color: isEven ? theme.colorScheme.surfaceContainerLow : null,
|
|
||||||
padding: const EdgeInsets.only(left: 4, right: 12),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SelectionContainer.disabled(
|
|
||||||
child: SizedBox(
|
|
||||||
width: gutterWidth,
|
|
||||||
child: Text(
|
|
||||||
'$number',
|
|
||||||
textAlign: TextAlign.right,
|
|
||||||
style: _codeStyle.copyWith(color: theme.hintColor),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(child: Text(text.isEmpty ? ' ' : text, style: _codeStyle)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// A single line of the text/code viewer: a right-aligned, non-selectable line
|
||||||
|
/// number gutter plus the selectable line content, with zebra striping.
|
||||||
|
class CodeLine extends StatelessWidget {
|
||||||
|
final int number;
|
||||||
|
final String text;
|
||||||
|
final double gutterWidth;
|
||||||
|
const CodeLine({
|
||||||
|
super.key,
|
||||||
|
required this.number,
|
||||||
|
required this.text,
|
||||||
|
required this.gutterWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const TextStyle _codeStyle = TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.4,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isEven = number.isEven;
|
||||||
|
return Container(
|
||||||
|
color: isEven ? theme.colorScheme.surfaceContainerLow : null,
|
||||||
|
padding: const EdgeInsets.only(left: 4, right: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SelectionContainer.disabled(
|
||||||
|
child: SizedBox(
|
||||||
|
width: gutterWidth,
|
||||||
|
child: Text(
|
||||||
|
'$number',
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: _codeStyle.copyWith(color: theme.hintColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(text.isEmpty ? ' ' : text, style: _codeStyle)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||||
|
|
||||||
|
import '../app_progress_indicator.dart';
|
||||||
|
|
||||||
|
/// SfPdfViewer asserts on `localToGlobal` if mounted during the page-push
|
||||||
|
/// animation. Defer until the route enter animation completes.
|
||||||
|
class DeferredPdfViewer extends StatefulWidget {
|
||||||
|
const DeferredPdfViewer({super.key, required this.path});
|
||||||
|
final String path;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DeferredPdfViewer> createState() => _DeferredPdfViewerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeferredPdfViewerState extends State<DeferredPdfViewer> {
|
||||||
|
bool _ready = false;
|
||||||
|
Animation<double>? _routeAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (_ready || _routeAnimation != null) return;
|
||||||
|
final animation = ModalRoute.of(context)?.animation;
|
||||||
|
if (animation == null || animation.isCompleted) {
|
||||||
|
_ready = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_routeAnimation = animation..addStatusListener(_onAnimationStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAnimationStatus(AnimationStatus status) {
|
||||||
|
if (status == AnimationStatus.completed && mounted) {
|
||||||
|
setState(() => _ready = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_routeAnimation?.removeStatusListener(_onAnimationStatus);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_ready) {
|
||||||
|
return const Center(child: AppProgressIndicator.large());
|
||||||
|
}
|
||||||
|
return SfPdfViewer.file(File(widget.path));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
enum FileKind { image, svg, pdf, text, video, audio, unknown }
|
||||||
|
|
||||||
|
const Set<String> _imageExtensions = {
|
||||||
|
'png',
|
||||||
|
'jpg',
|
||||||
|
'jpeg',
|
||||||
|
'webp',
|
||||||
|
'gif',
|
||||||
|
'bmp',
|
||||||
|
'wbmp',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Set<String> _videoExtensions = {
|
||||||
|
'mp4',
|
||||||
|
'm4v',
|
||||||
|
'mov',
|
||||||
|
'webm',
|
||||||
|
'mkv',
|
||||||
|
'3gp',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// ogg/opus/flac are Android-only; iOS init errors fall through to the
|
||||||
|
/// "format not supported" message.
|
||||||
|
const Set<String> _audioExtensions = {
|
||||||
|
'mp3',
|
||||||
|
'm4a',
|
||||||
|
'aac',
|
||||||
|
'wav',
|
||||||
|
'flac',
|
||||||
|
'ogg',
|
||||||
|
'oga',
|
||||||
|
'opus',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Unknown extensions still get a content sniff via [_looksLikeText].
|
||||||
|
const Set<String> _textExtensions = {
|
||||||
|
'txt', 'md', 'markdown', 'rst', 'log',
|
||||||
|
'json', 'json5', 'xml', 'yaml', 'yml', 'toml',
|
||||||
|
'csv', 'tsv', 'tab',
|
||||||
|
'ini', 'conf', 'cfg', 'env', 'properties',
|
||||||
|
'html', 'htm', 'xhtml',
|
||||||
|
'css', 'scss', 'sass', 'less',
|
||||||
|
'js', 'mjs', 'cjs', 'ts', 'jsx', 'tsx',
|
||||||
|
'dart', 'java', 'kt', 'kts', 'groovy', 'scala', 'swift',
|
||||||
|
'py', 'rb', 'pl', 'lua', 'r',
|
||||||
|
'go', 'rs', 'zig',
|
||||||
|
'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'cs', 'm', 'mm',
|
||||||
|
'php', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
|
||||||
|
'sql', 'graphql', 'gql',
|
||||||
|
'gitignore', 'gitattributes', 'editorconfig', 'dockerignore',
|
||||||
|
'dockerfile', 'makefile', 'cmake',
|
||||||
|
'tex', 'bib',
|
||||||
|
'srt', 'vtt',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Detects the [FileKind] of the file at [path] from its extension, falling
|
||||||
|
/// back to an 8 KB content sniff ([_looksLikeText]) for unknown extensions.
|
||||||
|
Future<FileKind> detectFileKind(String path) async {
|
||||||
|
final ext = path.split('.').last.toLowerCase();
|
||||||
|
if (_imageExtensions.contains(ext)) return FileKind.image;
|
||||||
|
if (ext == 'svg') return FileKind.svg;
|
||||||
|
if (ext == 'pdf') return FileKind.pdf;
|
||||||
|
if (_videoExtensions.contains(ext)) return FileKind.video;
|
||||||
|
if (_audioExtensions.contains(ext)) return FileKind.audio;
|
||||||
|
if (_textExtensions.contains(ext)) return FileKind.text;
|
||||||
|
if (await _looksLikeText(path)) return FileKind.text;
|
||||||
|
return FileKind.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 8 KB sniff: NUL bytes or non-UTF-8 sequences disqualify.
|
||||||
|
Future<bool> _looksLikeText(String path) async {
|
||||||
|
final file = File(path);
|
||||||
|
RandomAccessFile? raf;
|
||||||
|
try {
|
||||||
|
final length = await file.length();
|
||||||
|
if (length == 0) return true;
|
||||||
|
raf = await file.open();
|
||||||
|
final sample = await raf.read(min(length, 8192));
|
||||||
|
if (sample.contains(0)) return false;
|
||||||
|
utf8.decode(sample);
|
||||||
|
return true;
|
||||||
|
} on Object {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
await raf?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:chewie/chewie.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
import '../app_progress_indicator.dart';
|
||||||
|
|
||||||
|
/// Plays a local video (via Chewie) or audio file (via [_AudioControls]).
|
||||||
|
/// Reports an inline "format not supported" message when the platform can't
|
||||||
|
/// initialize the file.
|
||||||
|
class MediaPlayer extends StatefulWidget {
|
||||||
|
final String path;
|
||||||
|
final bool isAudio;
|
||||||
|
final String? filename;
|
||||||
|
const MediaPlayer({
|
||||||
|
super.key,
|
||||||
|
required this.path,
|
||||||
|
required this.isAudio,
|
||||||
|
this.filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MediaPlayer> createState() => _MediaPlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MediaPlayerState extends State<MediaPlayer> {
|
||||||
|
VideoPlayerController? _video;
|
||||||
|
ChewieController? _chewie;
|
||||||
|
Object? _initError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initialize() async {
|
||||||
|
final controller = VideoPlayerController.file(File(widget.path));
|
||||||
|
try {
|
||||||
|
await controller.initialize();
|
||||||
|
} on Object catch (e) {
|
||||||
|
await controller.dispose();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _initError = e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted) {
|
||||||
|
await controller.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (widget.isAudio) {
|
||||||
|
controller.addListener(_onAudioTick);
|
||||||
|
setState(() => _video = controller);
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_video = controller;
|
||||||
|
_chewie = ChewieController(
|
||||||
|
videoPlayerController: controller,
|
||||||
|
autoPlay: false,
|
||||||
|
looping: false,
|
||||||
|
allowFullScreen: true,
|
||||||
|
allowPlaybackSpeedChanging: true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAudioTick() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_video?.removeListener(_onAudioTick);
|
||||||
|
_chewie?.dispose();
|
||||||
|
_video?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_initError != null) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
widget.isAudio
|
||||||
|
? 'Audio kann nicht abgespielt werden'
|
||||||
|
: 'Video kann nicht abgespielt werden',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Format wird auf diesem Gerät nicht unterstützt. Über das Menü kannst du die Datei in einer anderen App öffnen.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_video == null) {
|
||||||
|
return const Center(child: AppProgressIndicator.large());
|
||||||
|
}
|
||||||
|
if (widget.isAudio) {
|
||||||
|
return _AudioControls(
|
||||||
|
controller: _video!,
|
||||||
|
filename: widget.filename ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Chewie(controller: _chewie!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AudioControls extends StatelessWidget {
|
||||||
|
final VideoPlayerController controller;
|
||||||
|
final String filename;
|
||||||
|
const _AudioControls({required this.controller, required this.filename});
|
||||||
|
|
||||||
|
String _format(Duration d) {
|
||||||
|
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||||
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||||
|
if (d.inHours > 0) return '${d.inHours}:$m:$s';
|
||||||
|
return '$m:$s';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final value = controller.value;
|
||||||
|
final duration = value.duration;
|
||||||
|
final position = value.position;
|
||||||
|
final maxMs = duration.inMilliseconds == 0 ? 1 : duration.inMilliseconds;
|
||||||
|
final posMs = position.inMilliseconds.clamp(0, maxMs).toDouble();
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.audiotrack,
|
||||||
|
size: 96,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
filename,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
Slider(
|
||||||
|
min: 0,
|
||||||
|
max: maxMs.toDouble(),
|
||||||
|
value: posMs,
|
||||||
|
onChanged: (v) =>
|
||||||
|
controller.seekTo(Duration(milliseconds: v.toInt())),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_format(position),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_format(duration),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FloatingActionButton(
|
||||||
|
heroTag: 'audioPlayPause',
|
||||||
|
onPressed: () {
|
||||||
|
if (value.isPlaying) {
|
||||||
|
controller.pause();
|
||||||
|
} else {
|
||||||
|
controller.play();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Icon(value.isPlaying ? Icons.pause : Icons.play_arrow),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../model/account_data.dart';
|
||||||
|
import '../../model/endpoint_data.dart';
|
||||||
|
import '../../share_intent/remote_file_ref.dart';
|
||||||
|
import '../app_progress_indicator.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';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Header block for the "Vorschau nicht verfügbar" screen.
|
||||||
|
///
|
||||||
|
/// Two visual modes — kept layout-equivalent so the screen looks identical
|
||||||
|
/// whether the server already said "no preview" or the probe failed late:
|
||||||
|
/// * **No preview available** (server said no, no remoteFile, or probe
|
||||||
|
/// errored): compact "file icon + 'Vorschau nicht verfügbar' text".
|
||||||
|
/// * **Preview rendering / loaded**: mid-sized thumbnail without text.
|
||||||
|
class UnknownPreviewBlock extends StatefulWidget {
|
||||||
|
final RemoteFileRef? remoteFile;
|
||||||
|
const UnknownPreviewBlock({super.key, required this.remoteFile});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UnknownPreviewBlock> createState() => _UnknownPreviewBlockState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnknownPreviewBlockState extends State<UnknownPreviewBlock> {
|
||||||
|
static const double _previewSize = 180;
|
||||||
|
bool _failed = false;
|
||||||
|
|
||||||
|
Widget _compact(ThemeData theme) => Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.insert_drive_file_outlined, size: 60),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Vorschau nicht verfügbar',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final remote = widget.remoteFile;
|
||||||
|
final canProbe =
|
||||||
|
remote != null &&
|
||||||
|
remote.hasPreview != false &&
|
||||||
|
remote.fileId != null &&
|
||||||
|
!_failed;
|
||||||
|
if (!canProbe) return _compact(theme);
|
||||||
|
return SizedBox(
|
||||||
|
width: _previewSize,
|
||||||
|
height: _previewSize,
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
httpHeaders: AccountData().authHeaders(),
|
||||||
|
imageUrl: _ncPreviewUrl(remote, width: 360),
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
// Late probe failure: re-render into the compact layout so the
|
||||||
|
// screen doesn't keep a 180×180 box around a tiny icon. Deferred
|
||||||
|
// to the next frame because setState during build is illegal.
|
||||||
|
errorListener: (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) setState(() => _failed = true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
placeholder: (_, _) =>
|
||||||
|
const Center(child: AppProgressIndicator.large()),
|
||||||
|
// Briefly empty while the post-frame setState swaps layouts.
|
||||||
|
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||||||
|
imageBuilder: (_, imageProvider) => ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image(
|
||||||
|
image: imageProvider,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
width: _previewSize,
|
||||||
|
height: _previewSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user