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:
Bernd Steckmeister
2026-10-07 16:52:10 +02:00
co-authored by Claude Opus 5.5
parent ef1d881967
commit a1d64ec813
11 changed files with 735 additions and 63 deletions
+32 -2
View File
@@ -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>(
+183 -51
View File
@@ -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),
],
),
);
}
+2 -2
View File
@@ -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,
+21 -5
View File
@@ -344,7 +344,18 @@ class _MobileShell extends ConsumerWidget {
body = const RoomsPanel();
}
return PopScope(
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
final overlayStyle = (pt.isDark
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark)
.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: overlayStyle,
child: PopScope(
canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) {
@@ -358,13 +369,17 @@ class _MobileShell extends ConsumerWidget {
}
},
child: Scaffold(
backgroundColor: pt.bg0,
// bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste
// geht nahtlos in sie über.
backgroundColor: pt.bg1,
drawer: Drawer(
width: 80,
backgroundColor: pt.bg0,
child: const SpacesRail(),
child: const SafeArea(child: SpacesRail()),
),
body: Stack(
// SafeArea: Abstand zu Statusleiste/Kamera-Aussparung oben und zur
// Gesten-/Navigationsleiste unten (edge-to-edge, siehe main.dart).
body: SafeArea(child: Stack(
children: [
body,
if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice) ...[
@@ -400,7 +415,8 @@ class _MobileShell extends ConsumerWidget {
child: _NotificationBlockedBanner(),
),
],
),
)),
),
),
);
}
+10 -2
View File
@@ -33,8 +33,16 @@ void main() async {
await vod.init();
// Hide status bar and navigation bar for immersive feel
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
// Status- und Navigationsleiste bleiben sichtbar (edge-to-edge): die App
// zeichnet darunter, hält per SafeArea (Handy-Layout in app_shell.dart)
// aber Abstand. Vorher immersiveSticky – da schob sich die Statusleiste
// beim Herunterwischen über Kopfzeile und Text (Bernds Feedback 2026-10-07).
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarContrastEnforced: false,
));
runApp(const ProviderScope(child: PyramidBootstrap()));
}