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 createState() => _FileViewerState(); } enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud } enum _FileKind { image, svg, pdf, text, video, audio, unknown } const Set _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 _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 _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 _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 _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? _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 { final PhotoViewController photoViewController = PhotoViewController(); late SettingsCubit settings = context.read(); 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 _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 _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 actions = const [], bool showActionsMenu = true, }) => AppBar( title: Text(widget.path.split('/').last), actions: [ ...actions, if (showActionsMenu) PopupMenuButton( 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 _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)), ], ), ); } }