Files
Client/lib/widget/file_viewer.dart
T

517 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.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 '../routing/app_routes.dart';
import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/settings/bloc/settings_cubit.dart';
import '../utils/downloads/download_manager.dart';
import '../utils/screen_bound_image.dart';
import 'app_progress_indicator.dart';
import 'async_action_button.dart';
import 'centered_leading.dart';
import 'confirm_dialog.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 'share_position_origin.dart';
class FileViewer extends StatefulWidget {
final String path;
final bool openExternal;
/// Enables in-app "An Chat senden" / "In Dateien speichern" — these
/// need a server-side reference instead of the local cache path.
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 }
class _FileViewerState extends State<FileViewer> {
Future<_TextPayload>? _textPayload;
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) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _openExternallyAndPop(),
);
} else {
_fileKind = detectFileKind(widget.path);
}
}
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();
}
/// Android may clear the cache dir behind an open viewer at any time —
/// verify the file is still there before handing its path to an action.
bool _ensureLocalFile() {
if (File(widget.path).existsSync()) return true;
final remote = widget.remoteFile;
if (remote == null) {
InfoDialog.show(
context,
'Die Datei wurde vom System aus dem Zwischenspeicher entfernt. Bitte lade sie erneut herunter.',
title: 'Datei nicht mehr verfügbar',
);
return false;
}
ConfirmDialog(
title: 'Datei nicht mehr verfügbar',
content:
'Die Datei wurde vom System aus dem Zwischenspeicher entfernt.\nErneut herunterladen?',
confirmButton: 'Herunterladen',
onConfirm: () {
// Pop the viewer before starting so the fresh download auto-opens.
Navigator.of(context).pop();
unawaited(
DownloadManager.instance.start(
remotePath: remote.path,
name: remote.name,
remoteFile: remote,
),
);
},
).asDialog(context);
return false;
}
Future<void> _handleAction(FileViewingActions value) async {
switch (value) {
case FileViewingActions.openExternal:
if (!_ensureLocalFile()) return;
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:
if (!_ensureLocalFile()) return;
unawaited(
runWithErrorDialog(
context,
() => SharePlus.instance.share(
ShareParams(
files: [XFile(widget.path)],
sharePositionOrigin: SharePositionOrigin.get(context),
),
),
),
);
break;
case FileViewingActions.save:
if (!_ensureLocalFile()) return;
try {
final source = File(widget.path);
final size = await source.length();
// file_picker has no path/stream save API, so the whole file
// gets loaded into RAM. Cap big media; user falls back to share.
const maxBytes = 200 * 1024 * 1024;
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,
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>(
tooltip: 'Dateiaktionen',
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;
});
},
tooltip: 'Drehen',
icon: const Icon(Icons.rotate_right),
),
],
),
backgroundColor: Colors.white,
body: PhotoView(
controller: photoViewController,
maxScale: 3.0,
minScale: 0.1,
// 2× the screen stays sharp while zooming in; the 4096 px cap only
// bites on camera-sized photos.
imageProvider: screenBoundImage(
context,
FileImage(File(widget.path)),
scale: 2,
),
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>(
// Cached: a future created in build re-read the file on every rebuild.
future: _textPayload ??= compute(
_loadTextPayload,
(widget.path, _textViewMaxBytes),
),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: AppProgressIndicator.large());
}
final payload = snapshot.data!;
final lines = payload.lines;
// Stable gutter width — sized by the highest line number's digit count.
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() => Scaffold(
appBar: _appbar(showActionsMenu: false),
body: _buildUnknownPlaceholder(),
);
Widget _buildUnknownPlaceholder() {
final theme = Theme.of(context);
final descriptors = _availableActions();
return ListView(
padding: const EdgeInsets.symmetric(vertical: 24),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
UnknownPreviewBlock(remoteFile: widget.remoteFile),
const SizedBox(height: 16),
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;
}
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 List<String> lines;
final bool truncated;
const _TextPayload({required this.lines, required this.truncated});
}
/// Reads, prettifies (JSON) and splits a text file on a background isolate:
/// up to 5 MB of decoding and line splitting would otherwise freeze the UI.
Future<_TextPayload> _loadTextPayload((String, int) args) async {
final (path, maxBytes) = args;
final file = File(path);
final size = await file.length();
final ext = path.split('.').last.toLowerCase();
if (size <= maxBytes) {
final raw = await file.readAsString();
return _TextPayload(
lines: const LineSplitter().convert(_maybePrettify(raw, ext)),
truncated: false,
);
}
final raf = await file.open();
try {
final bytes = await raf.read(maxBytes);
// Truncated payloads stay raw — a parser would choke on the dangling tail.
return _TextPayload(
lines: const LineSplitter().convert(
utf8.decode(bytes, allowMalformed: true),
),
truncated: true,
);
} finally {
await raf.close();
}
}
/// Falls through to the original text on parse errors.
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;
}
}