import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:pdf/pdf.dart'; import 'package:printing/printing.dart'; import 'package:url_launcher/url_launcher.dart'; class PdfViewerScreen extends StatefulWidget { final String title; final String pdfUrl; const PdfViewerScreen({ super.key, required this.title, required this.pdfUrl, }); @override State createState() => _PdfViewerScreenState(); } class _PdfViewerScreenState extends State with SingleTickerProviderStateMixin { final TransformationController _transformationController = TransformationController(); late AnimationController _animationController; Animation? _zoomAnimation; TapDownDetails? _doubleTapDetails; Uint8List? _cachedBytes; @override void initState() { super.initState(); _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 250), )..addListener(() { if (_zoomAnimation != null) { _transformationController.value = _zoomAnimation!.value; } }); } @override void dispose() { _animationController.dispose(); _transformationController.dispose(); super.dispose(); } void _handleDoubleTap() { if (_animationController.isAnimating) return; final currentMatrix = _transformationController.value; final currentScale = currentMatrix.getMaxScaleOnAxis(); Matrix4 targetMatrix; if (currentScale > 1.05) { // Zoomed in -> smoothly animate back to standard size (scale 1.0) targetMatrix = Matrix4.identity(); } else { // At standard size -> zoom in to tapped point (2.5x) final position = _doubleTapDetails?.localPosition ?? Offset.zero; final scenePoint = _transformationController.toScene(position); final x = position.dx - scenePoint.dx * 2.5; final y = position.dy - scenePoint.dy * 2.5; targetMatrix = Matrix4.diagonal3Values(2.5, 2.5, 1.0) ..setTranslationRaw(x, y, 0.0); } _zoomAnimation = Matrix4Tween( begin: currentMatrix, end: targetMatrix, ).animate(CurvedAnimation( parent: _animationController, curve: Curves.easeOutCubic, )); _animationController.forward(from: 0); } Future _openExternal() async { final uri = Uri.parse(widget.pdfUrl); try { final launched = await launchUrl( uri, mode: LaunchMode.externalApplication, ); if (!launched) { await launchUrl(uri, mode: LaunchMode.platformDefault); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Browser konnte nicht geöffnet werden: $e'), backgroundColor: Colors.red, ), ); } } } Future _sharePdf() async { if (_cachedBytes != null) { final safeTitle = widget.title.replaceAll(RegExp(r'[\\/:*?<>|]'), '_'); await Printing.sharePdf( bytes: _cachedBytes!, filename: '$safeTitle.pdf', ); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('PDF wird noch geladen...')), ); } } @override Widget build(BuildContext context) { final safeTitle = widget.title.replaceAll(RegExp(r'[\\/:*?<>|]'), '_'); return Scaffold( appBar: AppBar( title: Text(widget.title, overflow: TextOverflow.ellipsis), actions: [ IconButton( icon: const Icon(Icons.share), tooltip: 'PDF teilen / drucken', onPressed: _sharePdf, ), IconButton( icon: const Icon(Icons.open_in_browser), tooltip: 'Im Browser öffnen', onPressed: _openExternal, ), ], ), body: SafeArea( top: false, child: PdfPreview.builder( build: (PdfPageFormat format) async { if (_cachedBytes != null) return _cachedBytes!; final response = await http .get(Uri.parse(widget.pdfUrl)) .timeout(const Duration(seconds: 15)); if (response.statusCode == 200) { _cachedBytes = response.bodyBytes; return response.bodyBytes; } throw Exception( 'Fehler beim Abrufen der Datei (${response.statusCode})'); }, canChangeOrientation: false, canChangePageFormat: false, canDebug: false, allowPrinting: false, allowSharing: false, useActions: false, pdfFileName: '$safeTitle.pdf', loadingWidget: const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('Aussendung wird geladen...'), ], ), ), onError: (context, error) => Center( child: Padding( padding: const EdgeInsets.all(24.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.error_outline, color: Colors.red, size: 48), const SizedBox(height: 12), Text( 'Fehler beim Laden des Dokuments: $error', textAlign: TextAlign.center, style: const TextStyle(color: Colors.red), ), const SizedBox(height: 16), ElevatedButton.icon( onPressed: _openExternal, icon: const Icon(Icons.open_in_browser), label: const Text('Direkt im Browser öffnen'), ), ], ), ), ), pagesBuilder: (context, pages) { return LayoutBuilder( builder: (context, constraints) { final viewportWidth = constraints.maxWidth; return GestureDetector( behavior: HitTestBehavior.translucent, onDoubleTapDown: (details) => _doubleTapDetails = details, onDoubleTap: _handleDoubleTap, child: InteractiveViewer( transformationController: _transformationController, minScale: 1.0, maxScale: 5.0, panEnabled: true, scaleEnabled: true, constrained: false, boundaryMargin: const EdgeInsets.symmetric( horizontal: 48, vertical: 80), child: SizedBox( width: viewportWidth, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 8.0, vertical: 16.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ for (int i = 0; i < pages.length; i++) ...[ Container( margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(4), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.15), blurRadius: 10, offset: const Offset(0, 3), ), ], ), child: Image( image: pages[i].image, fit: BoxFit.contain, ), ), if (pages.length > 1) Padding( padding: const EdgeInsets.only(bottom: 12.0), child: Text( 'Seite ${i + 1} von ${pages.length}', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: Colors.grey.shade700, ), ), ), ], ], ), ), ), ), ); }, ); }, ), ), ); } }