part of '../document_viewer.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; } } 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'))); } } } // 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 _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. 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() ..translateByDouble(cx, cy, 0, 1) ..scaleByDouble(applied, applied, applied, 1) ..translateByDouble(-cx, -cy, 0, 1); 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)), ), ), ); }, ), ); } }