Files
pyramid/lib/features/chat/media_viewer.dart
T
Bernd SteckmeisterandClaude Opus 5.5 a1d64ec813 feat: Handy - Platz fuer Statusleiste, randlose Bild-Vollansicht mit Gesten, groessere Medien im Chat
Bernds Beta-Feedback:
- main.dart: edgeToEdge statt immersiveSticky (Statusleiste schob sich ueber
  den Inhalt); MobileShell mit SafeArea + Statusleisten-Symbolen passend
  zum Theme, Hintergrund bg1 = Kopfzeilenfarbe.
- MediaViewer: Handy randlos auf Schwarz (vorher 24-px-Rand, halbtransparent);
  Tippen neben das Bild schliesst, aufs Bild blendet Leiste ein/aus; Wischen
  hoch/runter schliesst (nur ohne Zoom, ueber InteractiveViewer-Callbacks,
  kein Gestenkonflikt mit Zwei-Finger-Zoom); Doppeltipp zoomt an der Stelle.
- Chat: Bilder auf dem Handy bis halbe Bildschirmhoehe statt fest 320 px;
  Videos im echten Seitenverhaeltnis statt fest 16:9.
- UI-Durchlauf integration_test/mobile_ui_screens_test.dart (Handy-Format,
  Testprofil, Screenshots nach build/ui_screens/) + Testinhalte-Helfer
  test/live_ui_fixture_test.dart; scripts/test.ps1 -Ui.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 16:52:10 +02:00

388 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
/// 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<void> 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: const Duration(milliseconds: 180),
transitionBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
pageBuilder: (context, animation, secondaryAnimation) => MediaViewer(
senderName: senderName,
filename: filename,
bytes: bytes,
networkUrl: networkUrl,
isVideo: isVideo,
event: event,
),
);
}
@override
State<MediaViewer> createState() => _MediaViewerState();
}
class _MediaViewerState extends State<MediaViewer>
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<Matrix4>? _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<void> _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<void> _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),
],
),
);
}
}