Files
Client/lib/widget/file_viewer.dart
T

849 lines
25 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:chewie/chewie.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:open_filex/open_filex.dart';
import 'package:photo_view/photo_view.dart';
import 'package:share_plus/share_plus.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:video_player/video_player.dart';
import '../routing/app_routes.dart';
import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/settings/bloc/settings_cubit.dart';
import 'app_progress_indicator.dart';
import 'centered_leading.dart';
import 'info_dialog.dart';
import 'share_position_origin.dart';
class FileViewer extends StatefulWidget {
final String path;
final bool openExternal;
/// When set, enables the in-app actions "An Chat senden" and "In Dateien
/// speichern" — these need a server-side reference, not the local cache
/// path. Aufrufer reichen die Referenz durch (siehe AppRoutes.openFileViewer).
final RemoteFileRef? remoteFile;
const FileViewer({
super.key,
required this.path,
this.openExternal = false,
this.remoteFile,
});
@override
State<FileViewer> createState() => _FileViewerState();
}
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',
};
/// Video container formats whose playback the platform decoders (ExoPlayer
/// on Android, AVPlayer on iOS) handle out of the box.
const Set<String> _videoExtensions = {
'mp4',
'm4v',
'mov',
'webm',
'mkv',
'3gp',
};
/// Audio formats playable through the same `video_player` pipeline. Some
/// (ogg/opus/flac) work on Android only — iOS will surface an init error
/// which we catch and surface as a friendly fallback.
const Set<String> _audioExtensions = {
'mp3',
'm4a',
'aac',
'wav',
'flac',
'ogg',
'oga',
'opus',
};
/// Extensions whose contents we render directly as plain text. Anything
/// outside this list still gets a content-based fallback check (see
/// [_looksLikeText]) so generic "what is this file" cases work too.
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',
};
/// Reads up to 8 KB and decides whether the bytes look like UTF-8 text.
/// NUL bytes and non-decodable sequences disqualify the file. Used as a
/// fallback for unknown extensions so plain text files without a familiar
/// suffix still open in the in-app viewer.
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();
}
}
/// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal
/// LayoutBuilder calls `localToGlobal` during build, which asserts when an
/// ancestor RenderTransform (from the page-push animation) is still mid-layout.
/// We wait for the route's enter animation to complete before mounting it.
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> {
final PhotoViewController photoViewController = PhotoViewController();
late SettingsCubit settings = context.read<SettingsCubit>();
late bool openExternal;
Future<_FileKind>? _fileKind;
@override
void initState() {
super.initState();
openExternal =
settings.val().fileViewSettings.alwaysOpenExternally ||
widget.openExternal;
if (openExternal) {
// Settings or popup explicitly chose "open externally" — fire and
// forget, then pop back. Same one-shot behaviour as the old viewer.
WidgetsBinding.instance.addPostFrameCallback(
(_) => _openExternallyAndPop(),
);
} else {
_fileKind = _detectKind();
}
}
Future<void> _openExternallyAndPop() async {
final result = await OpenFilex.open(widget.path);
if (!mounted) return;
Navigator.of(context).pop();
if (result.type != ResultType.done) {
InfoDialog.show(context, result.message);
}
}
@override
void dispose() {
photoViewController.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 {
switch (value) {
case FileViewingActions.openExternal:
AppRoutes.openFileViewer(
context,
widget.path,
openExternal: true,
remoteFile: widget.remoteFile,
);
break;
case FileViewingActions.sendToChat:
AppRoutes.openInternalShareToChat(context, widget.remoteFile!);
break;
case FileViewingActions.saveToCloud:
AppRoutes.openInternalSaveToFolder(context, widget.remoteFile!);
break;
case FileViewingActions.share:
unawaited(
SharePlus.instance.share(
ShareParams(
files: [XFile(widget.path)],
sharePositionOrigin: SharePositionOrigin.get(context),
),
),
);
break;
case FileViewingActions.save:
try {
final source = File(widget.path);
final size = await source.length();
// Hard-cap to avoid loading the entire file into memory just to
// hand it back to the platform's saveFile dialog. The package
// currently has no streaming/path-based save path, so for big
// media the user has to fall back to "Teilen" → save-to-files.
// 200 MB peak is comfortable on modern mid-range devices and big
// enough for typical school videos.
const maxBytes = 200 * 1024 * 1024; // 200 MB
if (size > maxBytes) {
if (!mounted) return;
InfoDialog.show(
context,
'Diese Datei ist zu groß (${(size / (1024 * 1024)).toStringAsFixed(0)} MB), '
'um direkt gespeichert zu werden. Nutze stattdessen die Teilen-Funktion.',
title: 'Speichern nicht möglich',
);
return;
}
final bytes = await source.readAsBytes();
final saved = await FilePicker.saveFile(
fileName: widget.path.split('/').last,
bytes: bytes,
);
if (!mounted) return;
if (saved != null) {
InfoDialog.show(context, 'Datei gespeichert.');
}
} on Object catch (e) {
if (!mounted) return;
InfoDialog.show(
context,
'Speichern fehlgeschlagen: $e',
copyable: true,
title: 'Fehler',
);
}
break;
}
}
List<_ActionDescriptor> _availableActions() => [
_ActionDescriptor(
action: FileViewingActions.openExternal,
// iOS opens the system share sheet (square-with-arrow icon), Android
// the standard app picker; mirror that visually and verbally.
icon: Platform.isIOS ? Icons.ios_share : Icons.open_in_new,
label: Platform.isIOS ? 'Extern öffnen' : 'Öffnen mit',
),
if (widget.remoteFile != null) ...[
const _ActionDescriptor(
action: FileViewingActions.sendToChat,
icon: Icons.chat_bubble_outline,
label: 'An Talk-Chat senden',
),
const _ActionDescriptor(
action: FileViewingActions.saveToCloud,
icon: Icons.cloud_outlined,
label: 'In Cloud speichern',
),
],
const _ActionDescriptor(
action: FileViewingActions.share,
icon: Icons.share_outlined,
label: 'Teilen',
),
const _ActionDescriptor(
action: FileViewingActions.save,
icon: Icons.save_alt_outlined,
label: 'Speichern',
),
];
AppBar _appbar({
List<Widget> actions = const [],
bool showActionsMenu = true,
}) => AppBar(
title: Text(widget.path.split('/').last),
actions: [
...actions,
if (showActionsMenu)
PopupMenuButton<FileViewingActions>(
onSelected: _handleAction,
itemBuilder: (context) => _availableActions()
.map(
(a) => PopupMenuItem(
value: a.action,
child: ListTile(
leading: Icon(a.icon),
title: Text(a.label),
dense: true,
),
),
)
.toList(),
),
],
);
@override
Widget build(BuildContext context) {
if (openExternal) {
return Scaffold(
appBar: AppBar(title: Text(widget.path.split('/').last)),
body: const Center(child: AppProgressIndicator.large()),
);
}
return FutureBuilder<_FileKind>(
future: _fileKind,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Scaffold(
appBar: _appbar(),
body: const Center(child: AppProgressIndicator.large()),
);
}
switch (snapshot.data!) {
case _FileKind.image:
return _buildImageView();
case _FileKind.svg:
return _buildSvgView();
case _FileKind.pdf:
return _buildPdfView();
case _FileKind.video:
return _buildVideoView();
case _FileKind.audio:
return _buildAudioView();
case _FileKind.text:
return _buildTextView();
case _FileKind.unknown:
return _buildUnknownView();
}
},
);
}
Widget _buildImageView() => Scaffold(
appBar: _appbar(
actions: [
IconButton(
onPressed: () {
setState(() {
photoViewController.rotation += pi / 2;
});
},
icon: const Icon(Icons.rotate_right),
),
],
),
backgroundColor: Colors.white,
body: PhotoView(
controller: photoViewController,
maxScale: 3.0,
minScale: 0.1,
imageProvider: Image.file(File(widget.path)).image,
backgroundDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
),
),
);
Widget _buildSvgView() => Scaffold(
appBar: _appbar(),
backgroundColor: Colors.white,
body: InteractiveViewer(
minScale: 0.5,
maxScale: 8,
child: Center(
child: SvgPicture.file(
File(widget.path),
placeholderBuilder: (_) =>
const Center(child: AppProgressIndicator.large()),
),
),
),
);
Widget _buildPdfView() =>
Scaffold(appBar: _appbar(), body: _DeferredPdfViewer(path: widget.path));
Widget _buildVideoView() => Scaffold(
appBar: _appbar(),
backgroundColor: Colors.black,
body: _MediaPlayer(path: widget.path, isAudio: false),
);
Widget _buildAudioView() => Scaffold(
appBar: _appbar(),
body: _MediaPlayer(
path: widget.path,
isAudio: true,
filename: widget.path.split('/').last,
),
);
Widget _buildTextView() => Scaffold(
appBar: _appbar(),
body: FutureBuilder<_TextPayload>(
future: _readTextPayload(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: AppProgressIndicator.large());
}
final payload = snapshot.data!;
final lines = const LineSplitter().convert(payload.content);
// Reserve gutter width by the digit count of the highest line number,
// so the gutter stays stable as the user scrolls down.
final gutterWidth = (lines.length.toString().length * 9.0) + 16;
return SelectionArea(
child: Scrollbar(
child: CustomScrollView(
slivers: [
if (payload.truncated)
SliverToBoxAdapter(
child: SelectionContainer.disabled(
child: Container(
width: double.infinity,
color: Theme.of(
context,
).colorScheme.surfaceContainerHigh,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'Datei ist groß — Anzeige auf die ersten ${(_textViewMaxBytes / 1024).round()} KB begrenzt.',
style: Theme.of(context).textTheme.bodySmall,
),
),
),
),
SliverList.builder(
itemCount: lines.length,
itemBuilder: (context, i) => _CodeLine(
number: i + 1,
text: lines[i],
gutterWidth: gutterWidth,
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
),
),
);
},
),
);
Widget _buildUnknownView() {
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),
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,
),
],
),
),
const SizedBox(height: 24),
...descriptors.map(
(d) => ListTile(
leading: CenteredLeading(Icon(d.icon)),
title: Text(d.label),
onTap: () => _handleAction(d.action),
),
),
],
),
);
}
static const int _textViewMaxBytes = 5 * 1024 * 1024;
Future<_TextPayload> _readTextPayload() async {
final file = File(widget.path);
final size = await file.length();
final ext = widget.path.split('.').last.toLowerCase();
if (size <= _textViewMaxBytes) {
final raw = await file.readAsString();
return _TextPayload(content: _maybePrettify(raw, ext), truncated: false);
}
final raf = await file.open();
try {
final bytes = await raf.read(_textViewMaxBytes);
// Truncated payloads cannot be reliably re-formatted (parser will
// choke on the dangling tail), so they stay raw.
return _TextPayload(
content: utf8.decode(bytes, allowMalformed: true),
truncated: true,
);
} finally {
await raf.close();
}
}
/// Re-indents JSON so dumped/minified payloads from the server are easier
/// to read. Falls through to the original text on parse errors so we
/// never destroy the user's content.
String _maybePrettify(String content, String ext) {
if (ext != 'json') return content;
try {
final parsed = jsonDecode(content);
return const JsonEncoder.withIndent(' ').convert(parsed);
} on Object {
return content;
}
}
}
class _ActionDescriptor {
final FileViewingActions action;
final IconData icon;
final String label;
const _ActionDescriptor({
required this.action,
required this.icon,
required this.label,
});
}
class _TextPayload {
final String content;
final bool truncated;
const _TextPayload({required this.content, required this.truncated});
}
/// Plays back a local file via `video_player`. Renders the standard Chewie
/// controls for video files; audio files get a centered icon plus a custom
/// transport row (slider, time, play/pause), since Chewie's chrome is
/// designed around a video frame.
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),
),
],
),
),
);
}
}
/// One row in the text viewer: line number on the left (not selectable so
/// it never ends up in copied selections), monospace content on the right.
/// Odd-numbered lines get a slightly tinted background so long files are
/// easier to scan.
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)),
],
),
);
}
}