454 lines
13 KiB
Dart
454 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
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 '../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 '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> {
|
|
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();
|
|
}
|
|
|
|
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();
|
|
// 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,
|
|
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);
|
|
// 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;
|
|
|
|
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 stay raw — a parser would choke on the dangling tail.
|
|
return _TextPayload(
|
|
content: 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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});
|
|
}
|