import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; import 'package:path_provider/path_provider.dart'; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/features/chat/media_player.dart'; import 'package:pyramid/utils/gif_favorite_service.dart'; import 'package:pyramid/widgets/spring_press.dart' show motionDuration; /// Overlay viewer for images, GIFs and videos. /// /// Bedienung (Bernds Feedback 2026-10-07, wie man es von WhatsApp & Co. kennt): /// * Handy: randlos auf schwarzem Grund (vorher 24-px-Rand, halbdurchsichtig). /// * Tippen NEBEN das Bild schließt, Tippen AUFS Bild blendet die Leiste ein/aus. /// * Nach oben/unten wischen schließt (nur ohne Zoom), der Hintergrund blendet /// dabei aus. /// * Doppeltipp zoomt an der Stelle hinein bzw. wieder heraus; mit zwei /// Fingern aufziehen geht weiterhin. class MediaViewer extends StatefulWidget { final String senderName; final Uint8List? bytes; final String? networkUrl; final String filename; final bool isVideo; final Event? event; const MediaViewer({ super.key, required this.senderName, required this.filename, this.bytes, this.networkUrl, this.isVideo = false, this.event, }); static Future show( BuildContext context, { required String senderName, required String filename, Uint8List? bytes, String? networkUrl, bool isVideo = false, Event? event, }) { return showGeneralDialog( context: context, barrierDismissible: true, barrierLabel: 'Schließen', // Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen // ausblenden kann). barrierColor: Colors.transparent, transitionDuration: motionDuration(context, 300), transitionBuilder: (context, animation, secondaryAnimation, child) => FadeTransition( opacity: animation, child: ScaleTransition( scale: Tween(begin: 0.9, end: 1.0).animate(CurvedAnimation( parent: animation, curve: Curves.easeOutBack, reverseCurve: Curves.easeIn, )), child: child, ), ), pageBuilder: (context, animation, secondaryAnimation) => MediaViewer( senderName: senderName, filename: filename, bytes: bytes, networkUrl: networkUrl, isVideo: isVideo, event: event, ), ); } @override State createState() => _MediaViewerState(); } class _MediaViewerState extends State with SingleTickerProviderStateMixin { /// Ab so viel Pixel Wischweg (oder entsprechend schnell) wird geschlossen. static const _dismissDistance = 120.0; static const _dismissVelocity = 1000.0; static const _doubleTapScale = 2.5; final _tc = TransformationController(); final _contentKey = GlobalKey(); late final AnimationController _zoomAnim; Animation? _zoomTween; bool _controlsVisible = true; bool _saving = false; bool _savingSticker = false; bool _zoomed = false; bool _dragging = false; double _dragOffset = 0; Offset? _doubleTapGlobal; @override void initState() { super.initState(); _zoomAnim = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), )..addListener(() { final tween = _zoomTween; if (tween != null) _tc.value = tween.value; }); _tc.addListener(_onTransform); } @override void dispose() { _zoomAnim.dispose(); _tc.removeListener(_onTransform); _tc.dispose(); super.dispose(); } void _onTransform() { final zoomed = _tc.value.getMaxScaleOnAxis() > 1.01; if (zoomed != _zoomed) setState(() => _zoomed = zoomed); } void _close() => Navigator.of(context).maybePop(); void _toggle() => setState(() => _controlsVisible = !_controlsVisible); void _animateTo(Matrix4 target) { _zoomTween = Matrix4Tween(begin: _tc.value, end: target).animate( CurvedAnimation(parent: _zoomAnim, curve: Curves.easeOutCubic), ); _zoomAnim.forward(from: 0); } void _resetZoom() => _animateTo(Matrix4.identity()); /// Doppeltipp: an der getippten Stelle hineinzoomen bzw. ganz heraus. void _toggleZoom() { final global = _doubleTapGlobal; final box = _contentKey.currentContext?.findRenderObject() as RenderBox?; if (_zoomed || global == null || box == null) { _resetZoom(); return; } final p = box.globalToLocal(global); _animateTo(Matrix4.identity() ..translateByDouble( -p.dx * (_doubleTapScale - 1), -p.dy * (_doubleTapScale - 1), 0, 1, ) ..scaleByDouble(_doubleTapScale, _doubleTapScale, 1, 1)); } // ── Wischen zum Schließen (über die InteractiveViewer-Callbacks, damit es // nicht mit dem Zwei-Finger-Zoom um die Gesten konkurriert) ───────────── void _onInteractionStart(ScaleStartDetails d) { _dragging = !_zoomed && d.pointerCount == 1; } void _onInteractionUpdate(ScaleUpdateDetails d) { if (!_dragging) return; if (d.pointerCount > 1) { // Zweiter Finger dazu → das ist ein Zoom, kein Wischen. setState(() { _dragging = false; _dragOffset = 0; }); return; } setState(() => _dragOffset += d.focalPointDelta.dy); } void _onInteractionEnd(ScaleEndDetails d) { if (!_dragging) return; _dragging = false; final fast = d.velocity.pixelsPerSecond.dy.abs() > _dismissVelocity; if (_dragOffset.abs() > _dismissDistance || fast) { _close(); } else { setState(() => _dragOffset = 0); } } // Video: kein InteractiveViewer, also normales vertikales Wischen. void _onVideoDragUpdate(DragUpdateDetails d) => setState(() => _dragOffset += d.delta.dy); void _onVideoDragEnd(DragEndDetails d) { final fast = d.velocity.pixelsPerSecond.dy.abs() > _dismissVelocity; if (_dragOffset.abs() > _dismissDistance || fast) { _close(); } else { setState(() => _dragOffset = 0); } } Future _saveAsSticker() async { final event = widget.event; if (event == null || _savingSticker) return; setState(() => _savingSticker = true); final messenger = ScaffoldMessenger.maybeOf(context); try { final ok = await GifFavoriteService.addImageAsStickerFavorite(event); messenger?.showSnackBar(SnackBar( content: Text(ok ? 'Als Sticker gespeichert' : 'Fehler beim Speichern'), duration: const Duration(seconds: 2), )); } finally { if (mounted) setState(() => _savingSticker = false); } } Future _save() async { final b = widget.bytes; if (b == null || _saving) return; setState(() => _saving = true); try { Directory? dir; if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { dir = await getDownloadsDirectory(); } dir ??= await getTemporaryDirectory(); final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'jpg'; final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext'; final file = File('${dir.path}/$name'); await file.writeAsBytes(b); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Gespeichert: ${file.path}'), duration: const Duration(seconds: 4), )); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Fehler: $e')), ); } } finally { if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); final isVideo = widget.isVideo && widget.bytes != null; final size = MediaQuery.sizeOf(context); final isMobile = size.width < 900 || size.height < 500; // Beim Wischen blendet der Hintergrund aus und zeigt, dass gleich zu ist. final dismissProgress = (_dragOffset.abs() / (_dismissDistance * 2.5)).clamp(0.0, 1.0); final bgAlpha = (isMobile ? 1.0 : 0.82) * (1 - dismissProgress); // Handy: randlos; Desktop: etwas Luft, damit das Fenster dahinter erkennbar bleibt. final margin = isMobile ? 0.0 : 24.0; final header = AnimatedOpacity( opacity: _controlsVisible && _dragOffset == 0 ? 1 : 0, duration: const Duration(milliseconds: 180), child: IgnorePointer( ignoring: !_controlsVisible, child: Container( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.black54, Colors.transparent], ), ), child: SafeArea( bottom: false, child: Row( children: [ IconButton( onPressed: _close, icon: const Icon(Icons.close_rounded, color: Colors.white), tooltip: 'Schließen', ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(widget.senderName, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600)), Text(widget.filename, style: const TextStyle(color: Colors.white60, fontSize: 12), overflow: TextOverflow.ellipsis), ], ), ), if (widget.event != null && !isVideo) IconButton( onPressed: _savingSticker ? null : _saveAsSticker, tooltip: 'Als Sticker speichern', icon: _savingSticker ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Icon(Icons.sticky_note_2_outlined, color: Colors.white), ), if (widget.bytes != null) IconButton( onPressed: _saving ? null : _save, tooltip: 'Speichern', icon: _saving ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Icon(Icons.download_rounded, color: Colors.white), ), if (!isVideo && _zoomed) IconButton( onPressed: _resetZoom, tooltip: 'Zoom zurücksetzen', icon: const Icon(Icons.zoom_out_map_rounded, color: Colors.white), ), ], ), ), ), ), ); final Widget content; if (isVideo) { content = GestureDetector( onVerticalDragUpdate: _onVideoDragUpdate, onVerticalDragEnd: _onVideoDragEnd, child: FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename), ); } else { Widget img; if (widget.bytes != null) { img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true); } else if (widget.networkUrl != null) { img = Image.network(widget.networkUrl!, fit: BoxFit.contain); } else { img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim); } content = InteractiveViewer( transformationController: _tc, minScale: 1.0, maxScale: 10.0, // Ohne Zoom gibt es nichts zu verschieben – die Ein-Finger-Bewegung // gehört dann dem Wischen zum Schließen. panEnabled: _zoomed, onInteractionStart: _onInteractionStart, onInteractionUpdate: _onInteractionUpdate, onInteractionEnd: _onInteractionEnd, child: Center( key: _contentKey, // Nur das Bild selbst fängt Tipps ab; daneben greift das Schließen. child: GestureDetector( onTap: _toggle, onDoubleTapDown: (d) => _doubleTapGlobal = d.globalPosition, onDoubleTap: _toggleZoom, child: img, ), ), ); } return Material( type: MaterialType.transparency, child: Stack( fit: StackFit.expand, children: [ // Hintergrund + „daneben tippen schließt“. GestureDetector( behavior: HitTestBehavior.opaque, onTap: _close, child: ColoredBox(color: Colors.black.withValues(alpha: bgAlpha)), ), Padding( padding: EdgeInsets.all(margin), child: Transform.translate( offset: Offset(0, _dragOffset), child: GestureDetector( // Tipps auf die leere Fläche um das Bild (innerhalb des // Inhaltsbereichs) schließen ebenfalls. behavior: HitTestBehavior.translucent, onTap: _close, child: content, ), ), ), Positioned(top: 0, left: 0, right: 0, child: header), ], ), ); } }