import 'dart:async'; 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: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 '../routing/app_routes.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart'; import 'info_dialog.dart'; import 'placeholder_view.dart'; import 'share_position_origin.dart'; class FileViewer extends StatefulWidget { final String path; final bool openExternal; const FileViewer({super.key, required this.path, this.openExternal = false}); @override State createState() => _FileViewerState(); } enum FileViewingActions { openExternal, share, save } /// 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? _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: CircularProgressIndicator()); } return SfPdfViewer.file(File(widget.path)); } } class _FileViewerState extends State { PhotoViewController photoViewController = PhotoViewController(); late SettingsCubit settings = context.read(); late bool openExternal; @override void initState() { openExternal = settings.val().fileViewSettings.alwaysOpenExternally || widget.openExternal; super.initState(); } @override Widget build(BuildContext context) { AppBar appbar({List actions = const []}) => AppBar( title: Text(widget.path.split('/').last), actions: [ ...actions, PopupMenuButton( onSelected: (value) async { switch(value) { case FileViewingActions.openExternal: AppRoutes.openFileViewer(context, widget.path, openExternal: true); break; case FileViewingActions.share: unawaited(SharePlus.instance.share( ShareParams( files: [XFile(widget.path)], sharePositionOrigin: SharePositionOrigin.get(context), ), )); break; case FileViewingActions.save: try { final bytes = await File(widget.path).readAsBytes(); final saved = await FilePicker.saveFile( fileName: widget.path.split('/').last, bytes: bytes, ); if (!context.mounted) return; if (saved != null) InfoDialog.show(context, 'Datei gespeichert.'); } on Object catch (e) { if (!context.mounted) return; InfoDialog.show(context, 'Speichern fehlgeschlagen: $e', copyable: true, title: 'Fehler'); } break; } }, itemBuilder: (context) => >[ const PopupMenuItem( value: FileViewingActions.openExternal, child: ListTile( leading: Icon(Icons.open_in_new), title: Text('Extern öffnen'), dense: true, ), ), const PopupMenuItem( value: FileViewingActions.share, child: ListTile( leading: Icon(Icons.share_outlined), title: Text('Teilen'), dense: true, ), ), const PopupMenuItem( value: FileViewingActions.save, child: ListTile( leading: Icon(Icons.save_alt_outlined), title: Text('Speichern'), dense: true, ), ), ], ), ], ); switch(openExternal ? '' : widget.path.split('.').last.toLowerCase()) { case 'png': case 'jpg': case 'jpeg': case 'webp': case 'gif': return 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), ) ); case 'pdf': return Scaffold( appBar: appbar(), body: _DeferredPdfViewer(path: widget.path), ); default: OpenFilex.open(widget.path).then((result) { if (!context.mounted) return; Navigator.of(context).pop(); if(result.type != ResultType.done) { showDialog(context: context, builder: (context) => AlertDialog( content: Text(result.message), )); } }); return PlaceholderView( text: 'Datei extern geöffnet', icon: Icons.open_in_new, button: TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Zurück'), ), ); } } }