- Handy: Chat gleitet federnd über die Chatliste (Liste bleibt aufgebaut, Scrollposition erhalten), beim Zurück wieder hinaus; Liste schon während des Hinausgleitens bedienbar. Mitgliederleiste gleitet herein. - Neue/gesendete Nachrichten ploppen weich auf (nur live, nicht beim Öffnen oder Nachladen), Reaktionen ploppen auf und hüpfen beim Hochzählen, Ungelesen-Zähler hüpft, Scroll-nach-unten-Knopf federt (Handy größer), Antwort-Leiste klappt federnd auf/zu, Senden-Pfeil ploppt, Vollansicht zoomt auf. Alles skaliert mit Einstellung „Animationen“ (0 % = aus). - Fix: Beim Raumwechsel blieben Antwort-Ziel, Auswahl und getippter Text am neuen Raum hängen (Antwort auf Raum A konnte in Raum B rausgehen). Jetzt eigenes Eingabefeld je Raum mit Entwurf im Speicher. - Antwort-Leiste: größeres Schließen-X auf dem Handy. - Tests: test/spring_motion_test.dart, UI-Durchlauf mit Übergängen. Co-Authored-By: Claude Opus 5.5 <[email protected]>
399 lines
13 KiB
Dart
399 lines
13 KiB
Dart
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<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: 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<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),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|