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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ef1d881967
commit
a1d64ec813
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
@@ -419,6 +420,15 @@ class _ChunkyBarsPainter extends CustomPainter {
|
||||
//
|
||||
// Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide)
|
||||
|
||||
/// Maximale Höhe für Bilder/Videos im Chat. Auf dem Handy bis zur halben
|
||||
/// Bildschirmhöhe – vorher galten überall 320 px, wodurch Hochkant-Fotos auf
|
||||
/// dem Handy nur ~180 px schmal waren (Bernds Feedback 2026-10-07).
|
||||
double inlineMediaMaxHeight(BuildContext context) {
|
||||
final screen = MediaQuery.sizeOf(context);
|
||||
final isMobile = screen.width < 900 || screen.height < 500;
|
||||
return isMobile ? (screen.height * 0.5).clamp(240.0, 560.0) : 320.0;
|
||||
}
|
||||
|
||||
class PyramidVideoPlayer extends StatefulWidget {
|
||||
final Uint8List bytes;
|
||||
final String filename;
|
||||
@@ -441,6 +451,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
||||
late final Player _player;
|
||||
late final VideoController _controller;
|
||||
bool _loading = true;
|
||||
// Echtes Seitenverhältnis, sobald das Video seine Größe kennt (vorher fest
|
||||
// 16:9 → Hochkant-Handyvideos klein zwischen schwarzen Balken).
|
||||
double? _aspect;
|
||||
final _subs = <StreamSubscription<int?>>[];
|
||||
double _speed = 1.0;
|
||||
String _quality = 'Auto';
|
||||
|
||||
@@ -452,9 +466,19 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
||||
super.initState();
|
||||
_player = Player();
|
||||
_controller = VideoController(_player);
|
||||
_subs.add(_player.stream.width.listen((_) => _updateAspect()));
|
||||
_subs.add(_player.stream.height.listen((_) => _updateAspect()));
|
||||
_init();
|
||||
}
|
||||
|
||||
void _updateAspect() {
|
||||
final w = _player.state.width;
|
||||
final h = _player.state.height;
|
||||
if (w == null || h == null || w <= 0 || h <= 0) return;
|
||||
final aspect = w / h;
|
||||
if (mounted && aspect != _aspect) setState(() => _aspect = aspect);
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4';
|
||||
final dir = await getTemporaryDirectory();
|
||||
@@ -466,6 +490,9 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final s in _subs) {
|
||||
s.cancel();
|
||||
}
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -506,7 +533,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 4, bottom: 4),
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 420,
|
||||
maxHeight: inlineMediaMaxHeight(context),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(pt.rLg),
|
||||
@@ -514,7 +544,7 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
aspectRatio: _aspect ?? 16 / 9,
|
||||
child: StreamBuilder<bool>(
|
||||
stream: _player.stream.playing,
|
||||
builder: (_, playSnap) => StreamBuilder<Duration>(
|
||||
|
||||
@@ -8,7 +8,14 @@ import 'package:pyramid/features/chat/media_player.dart';
|
||||
import 'package:pyramid/utils/gif_favorite_service.dart';
|
||||
|
||||
/// Overlay viewer for images, GIFs and videos.
|
||||
/// Uses a semi-transparent backdrop; tapping outside the content closes it.
|
||||
///
|
||||
/// 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;
|
||||
@@ -40,7 +47,9 @@ class MediaViewer extends StatefulWidget {
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'Schließen',
|
||||
barrierColor: Colors.black.withAlpha(178),
|
||||
// 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),
|
||||
@@ -59,21 +68,127 @@ class MediaViewer extends StatefulWidget {
|
||||
State<MediaViewer> createState() => _MediaViewerState();
|
||||
}
|
||||
|
||||
class _MediaViewerState extends State<MediaViewer> {
|
||||
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 _resetZoom() => _tc.value = Matrix4.identity();
|
||||
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;
|
||||
@@ -127,9 +242,16 @@ class _MediaViewerState extends State<MediaViewer> {
|
||||
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 ? 1 : 0,
|
||||
opacity: _controlsVisible && _dragOffset == 0 ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: IgnorePointer(
|
||||
ignoring: !_controlsVisible,
|
||||
@@ -146,7 +268,7 @@ class _MediaViewerState extends State<MediaViewer> {
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: _close,
|
||||
icon: const Icon(Icons.close_rounded, color: Colors.white),
|
||||
tooltip: 'Schließen',
|
||||
),
|
||||
@@ -181,7 +303,7 @@ class _MediaViewerState extends State<MediaViewer> {
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.download_rounded, color: Colors.white),
|
||||
),
|
||||
if (!isVideo)
|
||||
if (!isVideo && _zoomed)
|
||||
IconButton(
|
||||
onPressed: _resetZoom,
|
||||
tooltip: 'Zoom zurücksetzen',
|
||||
@@ -194,61 +316,71 @@ class _MediaViewerState extends State<MediaViewer> {
|
||||
),
|
||||
);
|
||||
|
||||
// Content area — constrained so the transparent Material outside it lets
|
||||
// pointer events through to the barrier (which dismisses on tap).
|
||||
final contentMaxW = (size.width - 48).clamp(200.0, 1200.0);
|
||||
final contentMaxH = (size.height - 48).clamp(200.0, 900.0);
|
||||
|
||||
final Widget content;
|
||||
if (isVideo) {
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
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(
|
||||
child: SizedBox(
|
||||
width: contentMaxW,
|
||||
height: contentMaxH,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename),
|
||||
Positioned(top: 0, left: 0, right: 0, child: header),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: contentMaxW,
|
||||
height: contentMaxH,
|
||||
child: GestureDetector(
|
||||
onTap: _toggle,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
InteractiveViewer(
|
||||
transformationController: _tc,
|
||||
minScale: 0.5,
|
||||
maxScale: 10.0,
|
||||
child: Center(child: img),
|
||||
),
|
||||
Positioned(top: 0, left: 0, right: 0, child: header),
|
||||
],
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ class _ImageContentState extends ConsumerState<_ImageContent> {
|
||||
final maxW = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth.clamp(80.0, 420.0)
|
||||
: 420.0;
|
||||
const maxH = 320.0;
|
||||
final maxH = inlineMediaMaxHeight(context);
|
||||
// Reserve the final display box up-front from the event's info dimensions,
|
||||
// so the layout doesn't shift (and yank the scroll position) when the image
|
||||
// finishes loading. Null when dimensions are absent.
|
||||
@@ -378,7 +378,7 @@ class _MediaContentState extends ConsumerState<_MediaContent> {
|
||||
final pt = widget.pt;
|
||||
final isSticker = widget.event.type == EventTypes.Sticker;
|
||||
final maxW = isSticker ? 160.0 : 420.0;
|
||||
final maxH = isSticker ? 160.0 : 320.0;
|
||||
final maxH = isSticker ? 160.0 : inlineMediaMaxHeight(context);
|
||||
|
||||
return FutureBuilder<_MediaResult>(
|
||||
future: _future,
|
||||
|
||||
Reference in New Issue
Block a user