import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:path_provider/path_provider.dart'; import 'package:pdfx/pdfx.dart'; import 'package:pyramid/core/theme.dart'; import 'package:url_launcher/url_launcher.dart'; // ─── Static render cache ────────────────────────────────────────────────────── // Survives widget dispose/recreate (e.g. scroll in chat list). // Key: filename + file-size-bytes + page + render-dimensions. // Evicts the oldest entry (FIFO) when the limit is reached. class _RenderCache { static const _max = 80; static final _data = {}; static String _k(String name, int sz, int page, int w, int h) => '$name\x00$sz\x00$page\x00${w}x$h'; static Uint8List? get(String name, int sz, int page, int w, int h) => _data[_k(name, sz, page, w, h)]; static void put(String name, int sz, int page, int w, int h, Uint8List img) { if (_data.length >= _max) _data.remove(_data.keys.first); _data[_k(name, sz, page, w, h)] = img; } } // ─── Inline chat preview ────────────────────────────────────────────────────── // Used by _DocPreviewContent in message_group.dart. // Shows the first page of a PDF as an image with page navigation arrows. // Tap → opens the full DocumentViewer overlay. class PdfInlinePreview extends StatefulWidget { final Uint8List bytes; final String filename; final PyramidTheme pt; final String senderName; final VoidCallback? onDownload; const PdfInlinePreview({ super.key, required this.bytes, required this.filename, required this.pt, required this.senderName, this.onDownload, }); @override State createState() => _PdfInlinePreviewState(); } class _PdfInlinePreviewState extends State { PdfDocument? _doc; int _currentPage = 1; int _pageCount = 0; double? _pageAspect; // width/height final Map _cache = {}; bool _loading = true; String? _error; bool _rendering = false; static const _previewW = 280.0; static const _renderScale = 2.0; @override void initState() { super.initState(); _init(); } Future _init() async { try { final doc = await PdfDocument.openData(widget.bytes); if (!mounted) { await doc.close(); return; } _doc = doc; _pageCount = doc.pagesCount; setState(() => _loading = false); await _ensurePage(1); } catch (e) { if (mounted) setState(() { _loading = false; _error = e.toString().split('\n').first; }); } } Future _ensurePage(int n) async { if (_cache.containsKey(n) || _rendering) return; _rendering = true; try { final page = await _doc!.getPage(n); if (mounted && _pageAspect == null) { setState(() => _pageAspect = page.width / page.height); } final rw = (page.width * _renderScale).round(); final rh = (page.height * _renderScale).round(); final hit = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh); if (hit != null) { await page.close(); if (mounted) setState(() => _cache[n] = hit); return; } final img = await page.render( width: page.width * _renderScale, height: page.height * _renderScale, format: PdfPageImageFormat.jpeg, backgroundColor: '#ffffff', ); await page.close(); if (img?.bytes != null) { _RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, img!.bytes); } if (mounted) setState(() => _cache[n] = img?.bytes); } catch (_) { _cache[n] = null; } finally { _rendering = false; } } void _goTo(int page) { if (page < 1 || page > _pageCount) return; setState(() => _currentPage = page); _ensurePage(page); _ensurePage((page + 1).clamp(1, _pageCount)); } void _openFull() { DocumentViewer.show( context, filename: widget.filename, bytes: widget.bytes, senderName: widget.senderName, initialPage: _currentPage, ); } @override void dispose() { _doc?.close(); super.dispose(); } @override Widget build(BuildContext context) { final pt = widget.pt; final radius = BorderRadius.circular(pt.rBase); if (_loading) { return Container( width: _previewW, // Match the typical loaded A4 height so the preview doesn't jump (and // shift the scroll position) once the first page finishes rendering. height: (_previewW / 0.707).clamp(160.0, 400.0), margin: const EdgeInsets.only(top: 4, bottom: 4), decoration: BoxDecoration( color: pt.bg2, borderRadius: radius, border: Border.all(color: pt.border), ), child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)), ); } if (_error != null) { return _FileFallback(filename: widget.filename, pt: pt, onDownload: widget.onDownload); } final pageBytes = _cache[_currentPage]; final aspect = _pageAspect ?? 0.707; // A4 fallback final imgH = (_previewW / aspect).clamp(160.0, 400.0); return Listener( onPointerSignal: (event) { if (event is PointerScrollEvent) { GestureBinding.instance.pointerSignalResolver.register(event, (resolved) { final dy = (resolved as PointerScrollEvent).scrollDelta.dy; if (dy > 0) { _goTo(_currentPage + 1); } else if (dy < 0) { _goTo(_currentPage - 1); } }); } }, child: GestureDetector( onTap: _openFull, onHorizontalDragEnd: (d) { if ((d.primaryVelocity ?? 0) < -200) _goTo(_currentPage + 1); if ((d.primaryVelocity ?? 0) > 200) _goTo(_currentPage - 1); }, child: Container( margin: const EdgeInsets.only(top: 4, bottom: 4), width: _previewW, decoration: BoxDecoration( borderRadius: radius, border: Border.all(color: pt.border), ), clipBehavior: Clip.hardEdge, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // Page image Container( width: _previewW, height: imgH, color: Colors.white, child: pageBytes != null ? Image.memory(pageBytes, width: _previewW, height: imgH, fit: BoxFit.cover, alignment: Alignment.topCenter) : Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)), ), // Footer Container( color: pt.bg2, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), child: Row( children: [ Icon(Icons.picture_as_pdf_outlined, size: 14, color: pt.fgDim), const SizedBox(width: 6), Expanded( child: Text( widget.filename, style: TextStyle(color: pt.fg, fontSize: 12, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis, ), ), if (_pageCount > 1) ...[ _NavArrow( icon: Icons.chevron_left_rounded, enabled: _currentPage > 1, pt: pt, onTap: () => _goTo(_currentPage - 1), ), Text( '$_currentPage/$_pageCount', style: TextStyle(color: pt.fgMuted, fontSize: 11, fontFeatures: const [FontFeature.tabularFigures()]), ), _NavArrow( icon: Icons.chevron_right_rounded, enabled: _currentPage < _pageCount, pt: pt, onTap: () => _goTo(_currentPage + 1), ), ], if (widget.onDownload != null) ...[ const SizedBox(width: 4), GestureDetector( onTap: widget.onDownload, behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.all(2), child: Icon(Icons.download_rounded, size: 16, color: pt.fgMuted), ), ), ], ], ), ), ], ), ), ), ); } } class _NavArrow extends StatelessWidget { final IconData icon; final bool enabled; final PyramidTheme pt; final VoidCallback onTap; const _NavArrow({required this.icon, required this.enabled, required this.pt, required this.onTap}); @override Widget build(BuildContext context) => GestureDetector( onTap: enabled ? onTap : null, behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 2), child: Icon(icon, size: 18, color: enabled ? pt.fg : pt.fgDim), ), ); } class _FileFallback extends StatelessWidget { final String filename; final PyramidTheme pt; final VoidCallback? onDownload; const _FileFallback({required this.filename, required this.pt, this.onDownload}); @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.only(top: 4, bottom: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), constraints: const BoxConstraints(maxWidth: 300), decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 36, height: 36, decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rSm)), child: Icon(Icons.picture_as_pdf_outlined, size: 20, color: pt.accent), ), const SizedBox(width: 10), Expanded( child: Text(filename, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), maxLines: 2, overflow: TextOverflow.ellipsis), ), if (onDownload != null) IconButton( onPressed: onDownload, icon: Icon(Icons.download_rounded, size: 20, color: pt.fgMuted), constraints: const BoxConstraints(minWidth: 32, minHeight: 32), padding: EdgeInsets.zero, ), ], ), ); } } // ─── Full overlay viewer ────────────────────────────────────────────────────── class DocumentViewer extends StatelessWidget { final String filename; final Uint8List bytes; final String senderName; final int initialPage; const DocumentViewer({ super.key, required this.filename, required this.bytes, required this.senderName, this.initialPage = 1, }); static bool supportsPreview(String filename) => _detectType(filename) != _DocType.unknown; /// True for formats rendered inline in chat (pdf/svg types, including ai/eps). static bool isInlinePreviewable(String filename) { final t = _detectType(filename); return t == _DocType.pdf || t == _DocType.svg; } /// True for formats that show a card inline in chat (needs download-on-tap). static bool showsInlineCard(String filename) { final t = _detectType(filename); return t == _DocType.proprietary || t == _DocType.psdThumb || t == _DocType.zipPreview; } /// Kept for compatibility — same as showsInlineCard. static bool isProprietaryFormat(String filename) => showsInlineCard(filename); static Future show( BuildContext context, { required String filename, required Uint8List bytes, required String senderName, int initialPage = 1, }) { return showGeneralDialog( context: context, barrierDismissible: true, barrierLabel: 'Schließen', barrierColor: Colors.black.withAlpha(178), transitionDuration: const Duration(milliseconds: 180), transitionBuilder: (ctx, anim, a, child) => FadeTransition(opacity: anim, child: child), pageBuilder: (ctx, a, b) => DocumentViewer( filename: filename, bytes: bytes, senderName: senderName, initialPage: initialPage, ), ); } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); final type = _detectType(filename); final size = MediaQuery.sizeOf(context); final maxW = (size.width - 48).clamp(300.0, 1100.0); final maxH = (size.height - 48).clamp(300.0, 900.0); return Material( type: MaterialType.transparency, child: Center( child: Container( width: maxW, height: maxH, decoration: BoxDecoration( color: pt.bg0, borderRadius: BorderRadius.circular(pt.rLg), border: Border.all(color: pt.border), boxShadow: [BoxShadow(color: Colors.black.withAlpha(80), blurRadius: 32, offset: const Offset(0, 8))], ), clipBehavior: Clip.hardEdge, child: Column( children: [ _DocHeader(filename: filename, senderName: senderName, pt: pt), Expanded( child: _DocContent( filename: filename, bytes: bytes, type: type, pt: pt, initialPage: initialPage, ), ), ], ), ), ), ); } } // ─── Header ────────────────────────────────────────────────────────────────── class _DocHeader extends StatelessWidget { final String filename; final String senderName; final PyramidTheme pt; const _DocHeader({required this.filename, required this.senderName, required this.pt}); @override Widget build(BuildContext context) { final ext = filename.contains('.') ? filename.split('.').last.toUpperCase() : ''; return Container( padding: const EdgeInsets.fromLTRB(16, 12, 8, 12), decoration: BoxDecoration( color: pt.bg1, border: Border(bottom: BorderSide(color: pt.border)), ), child: Row( children: [ _ExtBadge(ext: ext, pt: pt), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(filename, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis), Text(senderName, style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ), ), IconButton( onPressed: () => Navigator.of(context).pop(), icon: Icon(Icons.close_rounded, color: pt.fgMuted, size: 20), tooltip: 'Schließen', ), ], ), ); } } class _ExtBadge extends StatelessWidget { final String ext; final PyramidTheme pt; const _ExtBadge({required this.ext, required this.pt}); @override Widget build(BuildContext context) { final label = ext.length > 4 ? ext.substring(0, 4) : ext; return Container( width: 44, height: 44, decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rSm)), child: Center( child: Text(label, style: TextStyle(color: pt.accent, fontSize: label.length > 3 ? 10 : 12, fontWeight: FontWeight.w700)), ), ); } } // ─── Content dispatcher ─────────────────────────────────────────────────────── class _DocContent extends StatelessWidget { final String filename; final Uint8List bytes; final _DocType type; final PyramidTheme pt; final int initialPage; const _DocContent({required this.filename, required this.bytes, required this.type, required this.pt, required this.initialPage}); @override Widget build(BuildContext context) { return switch (type) { _DocType.pdf => _PdfFullViewer(bytes: bytes, filename: filename, pt: pt, initialPage: initialPage), _DocType.svg => _SvgViewer(bytes: bytes, pt: pt), _DocType.markdown => _MarkdownViewer(bytes: bytes, pt: pt), _DocType.text => _TextViewer(bytes: bytes, pt: pt, mono: false), _DocType.code => _TextViewer(bytes: bytes, pt: pt, mono: true), _DocType.office => _OfficeTextViewer(bytes: bytes, filename: filename, pt: pt), _DocType.archive => _ArchiveViewer(bytes: bytes, filename: filename, pt: pt), _DocType.psdThumb => _PsdThumbViewer(bytes: bytes, filename: filename, pt: pt), _DocType.zipPreview => _ZipPreviewViewer(bytes: bytes, filename: filename, pt: pt), _DocType.proprietary => _ProprietaryViewer(filename: filename, bytes: bytes, pt: pt), _DocType.unknown => _UnsupportedViewer(pt: pt), }; } } // ─── PDF full viewer with thumbnail strip ──────────────────────────────────── class _PdfFullViewer extends StatefulWidget { final Uint8List bytes; final String filename; final PyramidTheme pt; final int initialPage; const _PdfFullViewer({required this.bytes, required this.filename, required this.pt, required this.initialPage}); @override State<_PdfFullViewer> createState() => _PdfFullViewerState(); } class _PdfFullViewerState extends State<_PdfFullViewer> { late final PdfController _ctrl; PdfDocument? _thumbDoc; final Map _thumbs = {}; int _pageCount = 0; int _currentPage = 1; bool _animating = false; @override void initState() { super.initState(); _currentPage = widget.initialPage; _ctrl = PdfController( document: PdfDocument.openData(widget.bytes), initialPage: widget.initialPage, ); _ctrl.pageListenable.addListener(_onPage); _initThumbs(); } Future _initThumbs() async { final doc = await PdfDocument.openData(widget.bytes); if (!mounted) { await doc.close(); return; } _thumbDoc = doc; if (!mounted) { await doc.close(); _thumbDoc = null; return; } setState(() => _pageCount = doc.pagesCount); for (var i = 1; i <= doc.pagesCount; i++) { _renderThumb(i); } } Future _renderThumb(int n) async { final doc = _thumbDoc; if (doc == null) return; try { final page = await doc.getPage(n); final rw = (page.width * 0.12).round(); final rh = (page.height * 0.12).round(); final hit = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh); if (hit != null) { await page.close(); if (mounted) setState(() => _thumbs[n] = hit); return; } final img = await page.render( width: page.width * 0.12, height: page.height * 0.12, format: PdfPageImageFormat.jpeg, backgroundColor: '#ffffff', ); await page.close(); if (img?.bytes != null) { _RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, img!.bytes); } if (mounted) setState(() => _thumbs[n] = img?.bytes); } catch (_) { _thumbs[n] = null; } } void _onPage() => setState(() => _currentPage = _ctrl.page); void _onScrollPage(int delta) { if (_animating) return; final pageCount = _ctrl.pagesCount ?? _pageCount; if (pageCount == 0) return; final target = (_currentPage + delta).clamp(1, pageCount); if (target == _currentPage) return; _animating = true; _ctrl.animateToPage( target, duration: const Duration(milliseconds: 280), curve: Curves.easeInOut, ).then((_) { if (mounted) setState(() => _animating = false); }); } @override void dispose() { _ctrl.pageListenable.removeListener(_onPage); _ctrl.dispose(); _thumbDoc?.close(); super.dispose(); } @override Widget build(BuildContext context) { final pt = widget.pt; return Column( children: [ Expanded( child: Stack( children: [ Listener( onPointerSignal: (event) { if (event is PointerScrollEvent) { if (event.scrollDelta.dy > 0) { _onScrollPage(1); } else if (event.scrollDelta.dy < 0) { _onScrollPage(-1); } } }, child: PdfView( controller: _ctrl, scrollDirection: Axis.vertical, physics: const NeverScrollableScrollPhysics(), backgroundDecoration: BoxDecoration(color: pt.bg0), onPageChanged: (p) => setState(() => _currentPage = p), // Render at high resolution in PNG: sharper text/line-art and no // JPEG chroma artifacts (matters most for AI/EPS/vector content), // and gives headroom for the InteractiveViewer zoom. renderer: (page) => page.render( width: page.width * 4, height: page.height * 4, format: PdfPageImageFormat.png, backgroundColor: '#ffffff', ), builders: PdfViewBuilders( options: const DefaultBuilderOptions(), documentLoaderBuilder: (ctx) => Center( child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent), ), errorBuilder: (ctx, error) => _ErrorView(message: error.toString(), pt: pt), ), ), ), // Launch the immersive fullscreen page viewer. Positioned( top: 8, right: 8, child: Material( color: pt.bg0.withAlpha(200), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rSm), side: BorderSide(color: pt.border), ), child: IconButton( tooltip: 'Vollbild', icon: Icon(Icons.fullscreen_rounded, size: 22, color: pt.fg), onPressed: () => _PdfFullscreenViewer.show( context, bytes: widget.bytes, filename: widget.filename, initialPage: _currentPage, ), ), ), ), ], ), ), if (_pageCount > 1) _ThumbnailStrip( pageCount: _pageCount, currentPage: _currentPage, thumbs: _thumbs, pt: pt, onTap: (p) { _ctrl.jumpToPage(p); setState(() => _currentPage = p); }, ), ], ); } } // ─── Immersive fullscreen PDF viewer ───────────────────────────────────────── // Launched from the enlarged preview. Per-page high-res render, pan via drag, // zoom via +/− buttons (20%/click) and Ctrl+wheel, page nav via wheel/arrows. Future _saveBytes(BuildContext context, Uint8List bytes, String filename) async { try { Directory? dir; if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { dir = await getDownloadsDirectory(); } dir ??= await getTemporaryDirectory(); final ext = filename.contains('.') ? filename.split('.').last : 'bin'; final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext'; final file = File('${dir.path}/$name'); await file.writeAsBytes(bytes); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Gespeichert: ${file.path}'), duration: const Duration(seconds: 4)), ); } } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'))); } } } class _PdfFullscreenViewer extends StatefulWidget { final Uint8List bytes; final String filename; final int initialPage; const _PdfFullscreenViewer({required this.bytes, required this.filename, required this.initialPage}); static Future show(BuildContext context, { required Uint8List bytes, required String filename, required int initialPage, }) { return Navigator.of(context, rootNavigator: true).push(PageRouteBuilder( opaque: true, barrierColor: Colors.black, transitionDuration: const Duration(milliseconds: 180), pageBuilder: (_, _, _) => _PdfFullscreenViewer(bytes: bytes, filename: filename, initialPage: initialPage), transitionsBuilder: (_, anim, _, child) => FadeTransition(opacity: anim, child: child), )); } @override State<_PdfFullscreenViewer> createState() => _PdfFullscreenViewerState(); } class _PdfFullscreenViewerState extends State<_PdfFullscreenViewer> { PdfDocument? _doc; final _tc = TransformationController(); final _viewerKey = GlobalKey(); int _page = 1; int _count = 0; Uint8List? _img; bool _loading = true; bool _rendering = false; bool _uiVisible = true; bool get _isMobile => Platform.isAndroid || Platform.isIOS; static const _minScale = 1.0; static const _maxScale = 8.0; static const _renderScale = 5.0; // high-res page render for crisp zoom @override void initState() { super.initState(); _page = widget.initialPage; _open(); } Future _open() async { try { final doc = await PdfDocument.openData(widget.bytes); if (!mounted) { await doc.close(); return; } _doc = doc; _count = doc.pagesCount; await _render(_page); } catch (_) { if (mounted) setState(() => _loading = false); } } Future _render(int n) async { final doc = _doc; if (doc == null || _rendering) return; _rendering = true; if (mounted) setState(() => _loading = true); try { final page = await doc.getPage(n); final rw = (page.width * _renderScale).round(); final rh = (page.height * _renderScale).round(); var bytes = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh); if (bytes == null) { final img = await page.render( width: page.width * _renderScale, height: page.height * _renderScale, format: PdfPageImageFormat.png, backgroundColor: '#ffffff', ); bytes = img?.bytes; if (bytes != null) { _RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, bytes); } } await page.close(); if (mounted) setState(() { _img = bytes; _loading = false; }); } catch (_) { if (mounted) setState(() => _loading = false); } finally { _rendering = false; } } void _goto(int n) { final t = n.clamp(1, _count); if (t == _page) return; _tc.value = Matrix4.identity(); // reset zoom on page change setState(() => _page = t); _render(t); } // factor 1.2 = +20%, 1/1.2 = −20% void _zoom(double factor) { final size = _viewerKey.currentContext?.size; if (size == null) return; final current = _tc.value.getMaxScaleOnAxis(); final target = (current * factor).clamp(_minScale, _maxScale); final applied = target / current; if ((applied - 1).abs() < 0.001) return; final cx = size.width / 2, cy = size.height / 2; final m = _tc.value.clone() ..translate(cx, cy) ..scale(applied) ..translate(-cx, -cy); setState(() => _tc.value = m); } @override void dispose() { _doc?.close(); _tc.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); return Scaffold( backgroundColor: Colors.black, body: SafeArea( child: Stack( children: [ Positioned.fill( child: GestureDetector( // Single tap on the page (not on a control) toggles the UI. behavior: HitTestBehavior.translucent, onTap: () => setState(() => _uiVisible = !_uiVisible), child: Listener( onPointerSignal: (e) { if (e is! PointerScrollEvent) return; final ctrl = HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed; if (ctrl) { _zoom(e.scrollDelta.dy < 0 ? 1.2 : 1 / 1.2); } else { _goto(e.scrollDelta.dy > 0 ? _page + 1 : _page - 1); } }, child: _img == null ? Center( child: _loading ? CircularProgressIndicator(color: pt.accent) : const Icon(Icons.error_outline_rounded, color: Colors.white54, size: 40), ) : InteractiveViewer( key: _viewerKey, transformationController: _tc, minScale: _minScale, maxScale: _maxScale, // Pinch-zoom on mobile only. On desktop the built-in // scroll-wheel zoom is disabled so plain scrolling // pages instead; zoom is Ctrl+wheel and the +/− buttons. scaleEnabled: _isMobile, child: Center( child: Image.memory(_img!, fit: BoxFit.contain, gaplessPlayback: true), ), ), ), ), ), // Right-side control column if (_uiVisible) Positioned( right: 10, top: 0, bottom: 0, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ _FsBtn(icon: Icons.add_rounded, tooltip: 'Vergrößern', pt: pt, onTap: () => _zoom(1.2)), const SizedBox(height: 8), _FsBtn(icon: Icons.remove_rounded, tooltip: 'Verkleinern', pt: pt, onTap: () => _zoom(1 / 1.2)), const SizedBox(height: 8), _FsBtn(icon: Icons.download_rounded, tooltip: 'Herunterladen', pt: pt, onTap: () => _saveBytes(context, widget.bytes, widget.filename)), const SizedBox(height: 8), _FsBtn(icon: Icons.fullscreen_exit_rounded, tooltip: 'Vollbild beenden', pt: pt, onTap: () => Navigator.of(context).maybePop()), ], ), ), ), // Bottom-center page navigation if (_uiVisible && _count > 1) Positioned( bottom: 16, left: 0, right: 0, child: Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), decoration: BoxDecoration( color: Colors.black.withAlpha(160), borderRadius: BorderRadius.circular(24), border: Border.all(color: Colors.white24), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ _FsBtn(icon: Icons.chevron_left_rounded, tooltip: 'Vorherige', pt: pt, enabled: _page > 1, onTap: () => _goto(_page - 1), flat: true), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Text('$_page / $_count', style: const TextStyle(color: Colors.white, fontSize: 13, fontFeatures: [FontFeature.tabularFigures()])), ), _FsBtn(icon: Icons.chevron_right_rounded, tooltip: 'Nächste', pt: pt, enabled: _page < _count, onTap: () => _goto(_page + 1), flat: true), ], ), ), ), ), ], ), ), ); } } class _FsBtn extends StatelessWidget { final IconData icon; final String tooltip; final PyramidTheme pt; final VoidCallback onTap; final bool enabled; final bool flat; const _FsBtn({required this.icon, required this.tooltip, required this.pt, required this.onTap, this.enabled = true, this.flat = false}); @override Widget build(BuildContext context) { final btn = Material( color: flat ? Colors.transparent : Colors.black.withAlpha(160), shape: flat ? const CircleBorder() : RoundedRectangleBorder(borderRadius: BorderRadius.circular(10), side: const BorderSide(color: Colors.white24)), child: IconButton( tooltip: tooltip, icon: Icon(icon, color: enabled ? Colors.white : Colors.white30, size: 22), onPressed: enabled ? onTap : null, ), ); return btn; } } class _ThumbnailStrip extends StatefulWidget { final int pageCount; final int currentPage; final Map thumbs; final PyramidTheme pt; final ValueChanged onTap; const _ThumbnailStrip({required this.pageCount, required this.currentPage, required this.thumbs, required this.pt, required this.onTap}); @override State<_ThumbnailStrip> createState() => _ThumbnailStripState(); } class _ThumbnailStripState extends State<_ThumbnailStrip> { late final ScrollController _sc; @override void initState() { super.initState(); _sc = ScrollController(); } @override void didUpdateWidget(_ThumbnailStrip old) { super.didUpdateWidget(old); if (old.currentPage != widget.currentPage) { _scrollToPage(widget.currentPage); } } void _scrollToPage(int page) { const thumbW = 60.0 + 8.0; // thumb + margin final target = (page - 1) * thumbW - 80; if (_sc.hasClients) { _sc.animateTo( target.clamp(0.0, _sc.position.maxScrollExtent), duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); } } @override void dispose() { _sc.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final pt = widget.pt; return Container( height: 88, decoration: BoxDecoration( color: pt.bg1, border: Border(top: BorderSide(color: pt.border)), ), child: ListView.builder( controller: _sc, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), itemCount: widget.pageCount, itemBuilder: (ctx, i) { final page = i + 1; final isCurrent = page == widget.currentPage; final bytes = widget.thumbs[page]; return GestureDetector( onTap: () => widget.onTap(page), child: Container( width: 52, margin: const EdgeInsets.only(right: 8), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(4), border: Border.all( color: isCurrent ? pt.accent : pt.border, width: isCurrent ? 2 : 1, ), ), clipBehavior: Clip.hardEdge, child: bytes != null ? Image.memory(bytes, fit: BoxFit.cover) : Center( child: Text('$page', style: TextStyle(color: pt.fgDim, fontSize: 10)), ), ), ); }, ), ); } } // ─── SVG viewer ─────────────────────────────────────────────────────────────── class _SvgViewer extends StatelessWidget { final Uint8List bytes; final PyramidTheme pt; const _SvgViewer({required this.bytes, required this.pt}); @override Widget build(BuildContext context) { return Center( child: InteractiveViewer( minScale: 0.5, maxScale: 10, child: Padding( padding: const EdgeInsets.all(24), child: SvgPicture.memory(bytes), ), ), ); } } // ─── Markdown viewer ────────────────────────────────────────────────────────── class _MarkdownViewer extends StatelessWidget { final Uint8List bytes; final PyramidTheme pt; const _MarkdownViewer({required this.bytes, required this.pt}); @override Widget build(BuildContext context) { final String text; try { text = utf8.decode(bytes, allowMalformed: true); } catch (e) { return _ErrorView(message: e.toString(), pt: pt); } return Markdown( data: text, selectable: true, padding: const EdgeInsets.all(24), styleSheet: MarkdownStyleSheet( p: TextStyle(color: pt.fg, fontSize: 14, height: 1.65), h1: TextStyle(color: pt.fg, fontSize: 22, fontWeight: FontWeight.w700), h2: TextStyle(color: pt.fg, fontSize: 18, fontWeight: FontWeight.w600), h3: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600), h4: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), code: TextStyle(color: pt.accent, fontFamily: 'monospace', fontSize: 13, backgroundColor: pt.bg2), codeblockDecoration: BoxDecoration( color: pt.bg2, borderRadius: BorderRadius.circular(pt.rSm), border: Border.all(color: pt.border), ), blockquoteDecoration: BoxDecoration( border: Border(left: BorderSide(color: pt.accent, width: 3)), color: pt.accentSoft, ), blockquotePadding: const EdgeInsets.fromLTRB(12, 8, 12, 8), a: TextStyle(color: pt.accent, decoration: TextDecoration.underline, decorationColor: pt.accent), strong: TextStyle(color: pt.fg, fontWeight: FontWeight.w600), em: TextStyle(color: pt.fg, fontStyle: FontStyle.italic), listBullet: TextStyle(color: pt.fgMuted), horizontalRuleDecoration: BoxDecoration(border: Border(top: BorderSide(color: pt.border))), ), onTapLink: (text, href, title) { if (href != null) launchUrl(Uri.parse(href), mode: LaunchMode.externalApplication); }, ); } } // ─── Text / Code viewer ─────────────────────────────────────────────────────── class _TextViewer extends StatelessWidget { final Uint8List bytes; final PyramidTheme pt; final bool mono; static const _maxBytes = 512 * 1024; const _TextViewer({required this.bytes, required this.pt, required this.mono}); @override Widget build(BuildContext context) { final bool truncated = bytes.length > _maxBytes; final slice = truncated ? bytes.sublist(0, _maxBytes) : bytes; final String text; try { text = utf8.decode(slice, allowMalformed: true); } catch (e) { return _ErrorView(message: e.toString(), pt: pt); } return Column( children: [ if (truncated) Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), color: pt.bg2, child: Text('Zeige erste ${_maxBytes ~/ 1024} KB', style: TextStyle(color: pt.fgDim, fontSize: 11)), ), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(20), child: SelectableText( text, style: mono ? TextStyle(color: pt.fg, fontFamily: 'monospace', fontSize: 13, height: 1.5) : TextStyle(color: pt.fg, fontSize: 14, height: 1.65), ), ), ), ], ); } } // ─── Office text extraction (DOCX / PPTX / XLSX) ──────────────────────────── class _OfficeTextViewer extends StatefulWidget { final Uint8List bytes; final String filename; final PyramidTheme pt; const _OfficeTextViewer({required this.bytes, required this.filename, required this.pt}); @override State<_OfficeTextViewer> createState() => _OfficeTextViewerState(); } class _OfficeTextViewerState extends State<_OfficeTextViewer> { String? _text; String? _error; @override void initState() { super.initState(); _extract(); } void _extract() { try { final archive = ZipDecoder().decodeBytes(widget.bytes); final ext = widget.filename.contains('.') ? widget.filename.split('.').last.toLowerCase() : ''; final xmlFiles = []; for (final f in archive.files) { if (!f.isFile) continue; final n = f.name.toLowerCase(); if (ext == 'docx' && n == 'word/document.xml') { xmlFiles.add(f); break; } if (ext == 'xlsx' && n.startsWith('xl/worksheets/') && n.endsWith('.xml')) xmlFiles.add(f); if (ext == 'pptx' && n.startsWith('ppt/slides/slide') && n.endsWith('.xml')) xmlFiles.add(f); // ODF formats (Writer/Impress/Calc) all use content.xml if ((ext == 'odt' || ext == 'odp' || ext == 'ods') && n == 'content.xml') { xmlFiles.add(f); break; } } final buf = StringBuffer(); for (final f in xmlFiles) { final xml = utf8.decode(f.content as List, allowMalformed: true); // Strip XML tags, collapse whitespace final stripped = xml .replaceAll(RegExp(r'<[^>]+>'), ' ') .replaceAll(RegExp(r'&'), '&') .replaceAll(RegExp(r'<'), '<') .replaceAll(RegExp(r'>'), '>') .replaceAll(RegExp(r' '), ' ') .replaceAll(RegExp(r'\s+'), ' ') .trim(); buf.writeln(stripped); buf.writeln(); } _text = buf.toString().trim().isEmpty ? '(Kein lesbarer Text gefunden)' : buf.toString().trim(); } catch (e) { _error = e.toString().split('\n').first; } } @override Widget build(BuildContext context) { final pt = widget.pt; if (_error != null) return _ErrorView(message: _error!, pt: pt); return Column( children: [ Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), color: pt.bg2, child: Row( children: [ Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim), const SizedBox(width: 8), Text('Textvorschau (Formatierung nicht verfügbar)', style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ), ), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(20), child: SelectableText( _text ?? '', style: TextStyle(color: pt.fg, fontSize: 14, height: 1.65), ), ), ), ], ); } } // ─── Archive viewer ─────────────────────────────────────────────────────────── class _ArchiveViewer extends StatefulWidget { final Uint8List bytes; final String filename; final PyramidTheme pt; const _ArchiveViewer({required this.bytes, required this.filename, required this.pt}); @override State<_ArchiveViewer> createState() => _ArchiveViewerState(); } class _ArchiveViewerState extends State<_ArchiveViewer> { List<_ArchiveEntry>? _entries; String? _error; @override void initState() { super.initState(); _decode(); } void _decode() { try { final ext = widget.filename.contains('.') ? widget.filename.split('.').last.toLowerCase() : ''; Archive archive; if (ext == 'tar') { archive = TarDecoder().decodeBytes(widget.bytes); } else if (ext == 'gz' || ext == 'tgz') { final inner = GZipDecoder().decodeBytes(widget.bytes); archive = TarDecoder().decodeBytes(inner); } else { archive = ZipDecoder().decodeBytes(widget.bytes); } final entries = archive.files.map((f) => _ArchiveEntry(name: f.name, size: f.size, isDir: !f.isFile)).toList() ..sort((a, b) { if (a.isDir != b.isDir) return a.isDir ? -1 : 1; return a.name.compareTo(b.name); }); setState(() => _entries = entries); } catch (e) { setState(() => _error = e.toString()); } } String _fmtSize(int b) { if (b < 1024) return '$b B'; if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB'; return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB'; } @override Widget build(BuildContext context) { if (_error != null) return _ErrorView(message: _error!, pt: widget.pt); if (_entries == null) return Center(child: CircularProgressIndicator(strokeWidth: 2, color: widget.pt.accent)); final pt = widget.pt; final entries = _entries!; final totalBytes = entries.fold(0, (s, e) => s + (e.isDir ? 0 : e.size)); return Column( children: [ Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), color: pt.bg1, child: Row( children: [ Icon(Icons.folder_zip_outlined, size: 15, color: pt.fgDim), const SizedBox(width: 8), Text('${entries.length} Einträge · ${_fmtSize(totalBytes)} unkomprimiert', style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ), ), Expanded( child: ListView.builder( padding: const EdgeInsets.symmetric(vertical: 4), itemCount: entries.length, itemBuilder: (ctx, i) { final e = entries[i]; return ListTile( dense: true, visualDensity: VisualDensity.compact, leading: Icon(e.isDir ? Icons.folder_outlined : _fileIcon(e.name), size: 18, color: e.isDir ? pt.accent : pt.fgDim), title: Text(e.name, style: TextStyle(color: pt.fg, fontSize: 13), overflow: TextOverflow.ellipsis), trailing: e.isDir ? null : Text(_fmtSize(e.size), style: TextStyle(color: pt.fgDim, fontSize: 11)), ); }, ), ), ], ); } IconData _fileIcon(String name) { final ext = name.contains('.') ? name.split('.').last.toLowerCase() : ''; return switch (ext) { 'dart' || 'py' || 'js' || 'ts' || 'json' || 'xml' || 'yaml' || 'toml' => Icons.code_rounded, 'png' || 'jpg' || 'jpeg' || 'gif' || 'webp' || 'svg' => Icons.image_outlined, 'mp4' || 'mov' || 'avi' || 'mkv' => Icons.videocam_outlined, 'mp3' || 'ogg' || 'flac' || 'wav' => Icons.music_note_outlined, 'pdf' => Icons.picture_as_pdf_outlined, 'zip' || 'tar' || 'gz' || '7z' || 'rar' => Icons.folder_zip_outlined, 'md' || 'markdown' => Icons.article_outlined, _ => Icons.insert_drive_file_outlined, }; } } class _ArchiveEntry { final String name; final int size; final bool isDir; const _ArchiveEntry({required this.name, required this.size, required this.isDir}); } // ─── PSD/PSB thumbnail viewer ──────────────────────────────────────────────── // Extracts the JPEG thumbnail stored in PSD Image Resource 1036. Uint8List? _extractPsdThumbnail(Uint8List bytes) { try { if (bytes.length < 34) return null; final data = ByteData.sublistView(bytes); // Verify '8BPS' signature if (bytes[0] != 0x38 || bytes[1] != 0x42 || bytes[2] != 0x50 || bytes[3] != 0x53) return null; // PSD is big-endian throughout. // Color Mode Data section length at offset 26. final cmdLen = data.getUint32(26, Endian.big); // Image Resources section starts after 26-byte header + 4-byte length field + cmdLen bytes. final irPos = 30 + cmdLen; if (irPos + 4 > bytes.length) return null; final irLen = data.getUint32(irPos, Endian.big); int pos = irPos + 4; final irEnd = pos + irLen; while (pos + 10 <= irEnd && pos + 10 <= bytes.length) { // Signature: '8BIM' if (bytes[pos] != 0x38 || bytes[pos+1] != 0x42 || bytes[pos+2] != 0x49 || bytes[pos+3] != 0x4D) break; pos += 4; final resourceId = data.getUint16(pos, Endian.big); pos += 2; // Pascal string: 1-byte length + string, total padded to even. final nameLen = bytes[pos]; pos += (nameLen + 2) & ~1; if (pos + 4 > bytes.length) break; final dataSize = data.getUint32(pos, Endian.big); pos += 4; // Resource 1036 (Photoshop 5+) or 1033 (older) = Thumbnail if ((resourceId == 0x040C || resourceId == 0x0409) && dataSize >= 28) { final format = data.getUint32(pos, Endian.big); if (format == 1) { // kJpegRGB // Use dataSize-28 for the JPEG extent (more reliable than the // compressedSize field at pos+20, which varies across PS versions). final jpegStart = pos + 28; final jpegEnd = (pos + dataSize).clamp(jpegStart, bytes.length); if (jpegEnd > jpegStart) { final thumb = Uint8List.sublistView(bytes, jpegStart, jpegEnd); // Validate JPEG magic bytes (FF D8) if (thumb.length >= 2 && thumb[0] == 0xFF && thumb[1] == 0xD8) { return thumb; } // Some files need a forward scan for the actual JPEG start for (var i = 0; i < thumb.length - 1; i++) { if (thumb[i] == 0xFF && thumb[i + 1] == 0xD8) { return Uint8List.sublistView(thumb, i); } } } } return null; } // Advance past this resource's data (padded to even). final advance = (dataSize + 1) & ~1; if (advance == 0 && dataSize == 0) { pos += 0; } else { pos += advance; } } return null; } catch (_) { return null; } } class _PsdThumbViewer extends StatelessWidget { final Uint8List bytes; final String filename; final PyramidTheme pt; const _PsdThumbViewer({required this.bytes, required this.filename, required this.pt}); @override Widget build(BuildContext context) { final thumb = _extractPsdThumbnail(bytes); if (thumb == null) { return _ProprietaryViewer(filename: filename, bytes: bytes, pt: pt); } return Column( children: [ Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), color: pt.bg2, child: Row( children: [ Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim), const SizedBox(width: 8), Text('Eingebettete Vorschau (Vollbild nicht verfügbar)', style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ), ), Expanded( child: InteractiveViewer( minScale: 0.5, maxScale: 10, child: Padding( padding: const EdgeInsets.all(24), child: Image.memory(thumb, fit: BoxFit.contain), ), ), ), ], ); } } // ─── ZIP-preview viewer (Sketch, XD) ───────────────────────────────────────── // These formats are ZIP archives that embed a preview PNG. class _ZipPreviewViewer extends StatefulWidget { final Uint8List bytes; final String filename; final PyramidTheme pt; const _ZipPreviewViewer({required this.bytes, required this.filename, required this.pt}); @override State<_ZipPreviewViewer> createState() => _ZipPreviewViewerState(); } class _ZipPreviewViewerState extends State<_ZipPreviewViewer> { Uint8List? _preview; bool _loaded = false; bool _isImage = false; @override void initState() { super.initState(); _extract(); } void _extract() { try { final archive = ZipDecoder().decodeBytes(widget.bytes); // Priority search list const candidates = [ 'previews/preview.png', 'preview.png', 'thumbnail.png', 'thumbnails/thumbnail.png', 'preview.jpg', 'thumbnail.jpg', ]; ArchiveFile? found; for (final path in candidates) { found = archive.findFile(path); if (found != null && found.isFile) break; } // Fallback: any file with preview/thumbnail in the name if (found == null) { for (final f in archive.files) { if (!f.isFile) continue; final n = f.name.toLowerCase(); if ((n.contains('preview') || n.contains('thumb')) && (n.endsWith('.png') || n.endsWith('.jpg') || n.endsWith('.jpeg'))) { found = f; break; } } } if (found != null) { setState(() { _preview = Uint8List.fromList(found!.content as List); _isImage = true; _loaded = true; }); } else { setState(() => _loaded = true); } } catch (_) { setState(() => _loaded = true); } } @override Widget build(BuildContext context) { final pt = widget.pt; if (!_loaded) { return Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)); } if (_isImage && _preview != null) { return Column( children: [ Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), color: pt.bg2, child: Row( children: [ Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim), const SizedBox(width: 8), Text('Eingebettete Vorschau', style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ), ), Expanded( child: InteractiveViewer( minScale: 0.5, maxScale: 10, child: Padding( padding: const EdgeInsets.all(24), child: Image.memory(_preview!, fit: BoxFit.contain), ), ), ), ], ); } // No preview image found — fall back to archive listing return _ArchiveViewer(bytes: widget.bytes, filename: widget.filename, pt: pt); } } // ─── Proprietary format info card ──────────────────────────────────────────── class _ProprietaryViewer extends StatelessWidget { final String filename; final Uint8List bytes; final PyramidTheme pt; const _ProprietaryViewer({required this.filename, required this.bytes, required this.pt}); String _fmtSize(int b) { if (b < 1024) return '$b B'; if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB'; return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB'; } IconData _iconFor(String ext) => switch (ext) { 'psd' || 'psb' => Icons.photo_camera_outlined, 'ai' || 'eps' => Icons.gesture_outlined, 'indd' || 'inx' => Icons.menu_book_outlined, 'afphoto' || 'afdesign' || 'afpub' => Icons.palette_outlined, 'sketch' || 'fig' || 'xd' => Icons.design_services_outlined, 'doc' => Icons.description_outlined, 'ppt' => Icons.slideshow_outlined, 'xls' => Icons.table_chart_outlined, _ => Icons.insert_drive_file_outlined, }; @override Widget build(BuildContext context) { final ext = filename.contains('.') ? filename.split('.').last.toLowerCase() : ''; return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 80, height: 80, decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rBase)), child: Icon(_iconFor(ext), size: 40, color: pt.accent), ), const SizedBox(height: 16), Text( filename, style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600), textAlign: TextAlign.center, ), const SizedBox(height: 6), Text(_fmtSize(bytes.length), style: TextStyle(color: pt.fgDim, fontSize: 13)), const SizedBox(height: 16), Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( color: pt.bg2, borderRadius: BorderRadius.circular(pt.rSm), border: Border.all(color: pt.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.info_outline_rounded, size: 16, color: pt.fgDim), const SizedBox(width: 8), Text( 'Format kann nicht nativ dargestellt werden', style: TextStyle(color: pt.fgDim, fontSize: 13), ), ], ), ), ], ), ); } } // ─── Unsupported / Error ───────────────────────────────────────────────────── class _UnsupportedViewer extends StatelessWidget { final PyramidTheme pt; const _UnsupportedViewer({required this.pt}); @override Widget build(BuildContext context) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.preview_outlined, size: 48, color: pt.fgDim), const SizedBox(height: 12), Text('Vorschau nicht verfügbar', style: TextStyle(color: pt.fgDim, fontSize: 15)), const SizedBox(height: 4), Text('Proprietäres Format — bitte herunterladen.', style: TextStyle(color: pt.fgMuted, fontSize: 12)), ], ), ); } class _ErrorView extends StatelessWidget { final String message; final PyramidTheme pt; const _ErrorView({required this.message, required this.pt}); @override Widget build(BuildContext context) => Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline_rounded, size: 32, color: pt.danger), const SizedBox(height: 8), Text('Fehler beim Laden', style: TextStyle(color: pt.danger, fontSize: 14, fontWeight: FontWeight.w600)), const SizedBox(height: 6), Text(message, style: TextStyle(color: pt.fgMuted, fontSize: 12), textAlign: TextAlign.center), ], ), ), ); } // ─── Type detection ─────────────────────────────────────────────────────────── enum _DocType { pdf, svg, text, code, markdown, office, archive, psdThumb, zipPreview, proprietary, unknown } _DocType _detectType(String filename) { final ext = filename.contains('.') ? filename.split('.').last.toLowerCase() : ''; return switch (ext) { // AI/EPS files embed a full PDF when saved with PDF compatibility 'pdf' || 'ai' || 'eps' => _DocType.pdf, 'svg' => _DocType.svg, 'md' || 'markdown' || 'rst' => _DocType.markdown, 'zip' || 'tar' || 'gz' || 'tgz' || 'tar.gz' => _DocType.archive, 'txt' || 'log' || 'csv' || 'tsv' || 'rtf' || 'tex' || 'srt' || 'vtt' || 'nfo' => _DocType.text, // ODF formats are ZIP+XML, same extraction approach as OOXML 'docx' || 'xlsx' || 'pptx' || 'odt' || 'odp' || 'ods' => _DocType.office, 'dart' || 'py' || 'js' || 'mjs' || 'cjs' || 'ts' || 'jsx' || 'tsx' || 'json' || 'jsonl' || 'ndjson' || 'yaml' || 'yml' || 'xml' || 'plist' || 'html' || 'htm' || 'css' || 'scss' || 'sass' || 'less' || 'vue' || 'svelte' || 'astro' || 'sh' || 'bash' || 'zsh' || 'fish' || 'ps' || 'ps1' || 'bat' || 'cmd' || 'kt' || 'kts' || 'gradle' || 'java' || 'swift' || 'go' || 'rs' || 'cpp' || 'cc' || 'cxx' || 'c' || 'h' || 'hpp' || 'hxx' || 'm' || 'mm' || 'cs' || 'rb' || 'php' || 'r' || 'lua' || 'pl' || 'pm' || 'scala' || 'clj' || 'ex' || 'exs' || 'erl' || 'hs' || 'jl' || 'nim' || 'zig' || 'toml' || 'ini' || 'conf' || 'cfg' || 'env' || 'editorconfig' || 'properties' || 'diff' || 'patch' || 'gitignore' || 'dockerfile' || 'sql' || 'graphql' || 'gql' || 'proto' => _DocType.code, // PSD/PSB embed a JPEG thumbnail in Image Resource 1036 'psd' || 'psb' => _DocType.psdThumb, // Sketch and XD are ZIP archives containing a preview PNG 'sketch' || 'xd' => _DocType.zipPreview, // Truly proprietary — no extractable preview 'afphoto' || 'afdesign' || 'afpub' || 'indd' || 'inx' || 'fig' || 'doc' || 'ppt' || 'xls' => _DocType.proprietary, _ => _DocType.unknown, }; }