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
+20
View File
@@ -6,6 +6,7 @@ Stand: 2026-10-07. Alles läuft mit **einem Befehl** aus dem Projektordner:
pwsh scripts/test.ps1 # Analyse + Unit-Tests (~1 min) – vor jedem Commit pwsh scripts/test.ps1 # Analyse + Unit-Tests (~1 min) – vor jedem Commit
pwsh scripts/test.ps1 -Live # + Live-Test gegen steggi-matrix.work (Test-Account) pwsh scripts/test.ps1 -Live # + Live-Test gegen steggi-matrix.work (Test-Account)
pwsh scripts/test.ps1 -Bench # + Benchmarks (Datenbank/Verschlüsselung, Migration) pwsh scripts/test.ps1 -Bench # + Benchmarks (Datenbank/Verschlüsselung, Migration)
pwsh scripts/test.ps1 -Ui # + UI-Durchlauf im Handy-Format mit Screenshots
pwsh scripts/test.ps1 -All # alles (~4 min) pwsh scripts/test.ps1 -All # alles (~4 min)
``` ```
@@ -40,6 +41,25 @@ Live-Tests: Zugangsdaten liegen AUSSERHALB des Repos
(`%USERPROFILE%\.pyramid-autopilot\test-accounts.txt` bzw. `PYRAMID_TEST_ACCOUNTS`). (`%USERPROFILE%\.pyramid-autopilot\test-accounts.txt` bzw. `PYRAMID_TEST_ACCOUNTS`).
Es sind die Test-Accounts `pyramidtest1/2`, nie Bernds oder Utas Konten. Es sind die Test-Accounts `pyramidtest1/2`, nie Bernds oder Utas Konten.
## UI-Durchlauf (`integration_test/`, seit 2026-10-07)
`integration_test/mobile_ui_screens_test.dart` startet die ECHTE App
(Windows-Build) mit dem Testprofil `pyramidtest1`
(`PYRAMID_PROFILE_DIR=%USERPROFILE%\.pyramid-autopilot\profile1`) in
simulierter Handy-Größe (1080×2340, Statusleiste 24 dp, Gestenleiste 16 dp),
tippt sich durch Chatliste → Chat → Bild und prüft dabei: Vollansicht öffnet,
Tippen daneben schließt, Tippen aufs Bild schließt nicht, Doppeltipp zoomt,
Wischen nach unten schließt. Screenshots landen in `build/ui_screens/` – damit
lassen sich UI-Änderungen ohne Handy ansehen.
- Testinhalte (DM von pyramidtest2 mit langem Text, Antwort, Hoch- und
Querformat-Bild) legt `test/live_ui_fixture_test.dart` an
(`PYRAMID_LIVE_TEST=1`).
- Läuft nur, wenn keine Pyramid-Instanz offen ist (nie zwei Clients auf
einer Datenbank) – das Skript prüft das.
- Simulierte Fensterklicks (PostMessage) erreichen Flutter unter Windows
NICHT; deshalb dieser Weg über `integration_test`.
## Benchmarks (`benchmark/`) ## Benchmarks (`benchmark/`)
Laufen bewusst NICHT im normalen `flutter test` mit (nur `-Bench` bzw. Laufen bewusst NICHT im normalen `flutter test` mit (nur `-Bench` bzw.
@@ -0,0 +1,183 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pyramid/main.dart' as app;
/// UI-Durchlauf im HANDY-Format mit der echten App (Windows-Build) und einem
/// TEST-Profil – erzeugt Screenshots unter build/ui_screens/.
///
/// Läuft nur mit gesetztem PYRAMID_PROFILE_DIR (eingeloggtes Testprofil, z. B.
/// pyramidtest1 unter %USERPROFILE%\.pyramid-autopilot\profile1) – nie gegen
/// Bernds echtes Profil. Testinhalte vorher mit
/// `test/live_ui_fixture_test.dart` anlegen (DM von pyramidtest2 mit Bildern).
///
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
/// flutter test integration_test/mobile_ui_screens_test.dart -d windows
///
/// Achtung: Keine zweite Pyramid-Instanz auf demselben Profil laufen lassen
/// (zwei Clients auf einer olm-DB).
///
/// Simuliert wird ein typisches Handy: 1080×2340 px, Dichte 2,625
/// (≈ 411×891 dp), Statusleiste oben 24 dp, Gestenleiste unten 16 dp.
const _otherUser = 'pyramidtest2';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
testWidgets(
'Handy-UI: Chatliste, Chat, Bild-Vollansicht',
(tester) async {
tester.view
..physicalSize = const Size(1080, 2340)
..devicePixelRatio = 2.625
..padding = const FakeViewPadding(top: 63, bottom: 42)
..viewPadding = const FakeViewPadding(top: 63, bottom: 42);
addTearDown(tester.view.reset);
app.main();
await _pumpUntil(
tester,
find.text(_otherUser),
timeout: const Duration(seconds: 90),
);
await _shot(tester, '01_chatliste');
await tester.tap(find.text(_otherUser).first);
await _pumpFor(tester, const Duration(seconds: 6));
await _shot(tester, '02_chat');
// Größtes Bild im Chat = eines der Testbilder; erst sichtbar scrollen.
Future<void> openViewer() async {
final image = _largestImage(tester);
expect(image, isNotNull, reason: 'kein Bild im Test-Chat gefunden');
await tester.ensureVisible(image!);
await _pumpFor(tester, const Duration(milliseconds: 600));
await tester.tap(image);
await _pumpFor(tester, const Duration(seconds: 2));
expect(
find.byType(InteractiveViewer),
findsOneWidget,
reason: 'Antippen des Bildes hat die Vollansicht nicht geöffnet',
);
}
await openViewer();
await _shot(tester, '03_vollansicht');
// Tippen NEBEN das Bild (ganz unten, unter dem Hochkant-Bild) schließt.
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
await tester.tapAt(Offset(size.width / 2, size.height - 30));
await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '04_nach_tippen_daneben');
expect(
find.byType(InteractiveViewer),
findsNothing,
reason: 'Tippen neben das Bild hat die Vollansicht nicht geschlossen',
);
// Tippen AUFS Bild schließt NICHT (blendet nur die Leiste aus).
await openViewer();
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await _pumpFor(tester, const Duration(seconds: 1));
expect(
find.byType(InteractiveViewer),
findsOneWidget,
reason: 'Tippen aufs Bild darf die Vollansicht nicht schließen',
);
// Doppeltipp zoomt hinein.
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await tester.pump(const Duration(milliseconds: 80));
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await _pumpFor(tester, const Duration(seconds: 1));
final viewer = tester.widget<InteractiveViewer>(
find.byType(InteractiveViewer),
);
expect(
viewer.transformationController!.value.getMaxScaleOnAxis(),
greaterThan(1.5),
reason: 'Doppeltipp hat nicht gezoomt',
);
await _shot(tester, '05_doppeltipp_zoom');
// Nochmal doppelt → zurück auf 1.
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await tester.pump(const Duration(milliseconds: 80));
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await _pumpFor(tester, const Duration(seconds: 1));
// Wischen nach unten schließt.
await tester.dragFrom(
Offset(size.width / 2, size.height / 2),
const Offset(0, 320),
);
await _pumpFor(tester, const Duration(seconds: 2));
expect(
find.byType(InteractiveViewer),
findsNothing,
reason: 'Wischen nach unten hat die Vollansicht nicht geschlossen',
);
await _shot(tester, '06_nach_wischen');
},
skip: profile == null,
timeout: const Timeout(Duration(minutes: 5)),
);
}
Finder? _largestImage(WidgetTester tester) {
Element? best;
var bestArea = 150.0 * 150.0;
for (final element in find.byType(Image).evaluate()) {
final box = element.renderObject;
if (box is! RenderBox || !box.hasSize) continue;
final area = box.size.width * box.size.height;
if (area > bestArea) {
bestArea = area;
best = element;
}
}
return best == null ? null : find.byElementPredicate((e) => e == best);
}
Future<void> _pumpFor(WidgetTester tester, Duration duration) async {
final end = DateTime.now().add(duration);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 100));
}
}
Future<void> _pumpUntil(
WidgetTester tester,
Finder finder, {
required Duration timeout,
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (finder.evaluate().isNotEmpty) return;
}
fail('Zeitüberschreitung: $finder nicht gefunden');
}
/// Speichert, was gerade auf dem (simulierten) Bildschirm ist.
Future<void> _shot(WidgetTester tester, String name) async {
await tester.pump();
final renderView = tester.binding.renderViews.first;
final layer = renderView.debugLayer! as OffsetLayer;
final physical = tester.view.physicalSize;
final image = await tester.binding.runAsync(
() => layer.toImage(Offset.zero & physical),
);
final bytes = await tester.binding.runAsync(
() => image!.toByteData(format: ui.ImageByteFormat.png),
);
final file = File('build/ui_screens/$name.png');
file.parent.createSync(recursive: true);
file.writeAsBytesSync(bytes!.buffer.asUint8List());
// ignore: avoid_print
print('Screenshot: ${file.path}');
}
+32 -2
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
@@ -419,6 +420,15 @@ class _ChunkyBarsPainter extends CustomPainter {
// //
// Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide) // 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 { class PyramidVideoPlayer extends StatefulWidget {
final Uint8List bytes; final Uint8List bytes;
final String filename; final String filename;
@@ -441,6 +451,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
late final Player _player; late final Player _player;
late final VideoController _controller; late final VideoController _controller;
bool _loading = true; 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; double _speed = 1.0;
String _quality = 'Auto'; String _quality = 'Auto';
@@ -452,9 +466,19 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
super.initState(); super.initState();
_player = Player(); _player = Player();
_controller = VideoController(_player); _controller = VideoController(_player);
_subs.add(_player.stream.width.listen((_) => _updateAspect()));
_subs.add(_player.stream.height.listen((_) => _updateAspect()));
_init(); _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 { Future<void> _init() async {
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4'; final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4';
final dir = await getTemporaryDirectory(); final dir = await getTemporaryDirectory();
@@ -466,6 +490,9 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
@override @override
void dispose() { void dispose() {
for (final s in _subs) {
s.cancel();
}
_player.dispose(); _player.dispose();
super.dispose(); super.dispose();
} }
@@ -506,7 +533,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
return Container( return Container(
margin: const EdgeInsets.only(top: 4, bottom: 4), margin: const EdgeInsets.only(top: 4, bottom: 4),
constraints: const BoxConstraints(maxWidth: 420), constraints: BoxConstraints(
maxWidth: 420,
maxHeight: inlineMediaMaxHeight(context),
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black, color: Colors.black,
borderRadius: BorderRadius.circular(pt.rLg), borderRadius: BorderRadius.circular(pt.rLg),
@@ -514,7 +544,7 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
), ),
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
child: AspectRatio( child: AspectRatio(
aspectRatio: 16 / 9, aspectRatio: _aspect ?? 16 / 9,
child: StreamBuilder<bool>( child: StreamBuilder<bool>(
stream: _player.stream.playing, stream: _player.stream.playing,
builder: (_, playSnap) => StreamBuilder<Duration>( 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'; import 'package:pyramid/utils/gif_favorite_service.dart';
/// Overlay viewer for images, GIFs and videos. /// 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 { class MediaViewer extends StatefulWidget {
final String senderName; final String senderName;
final Uint8List? bytes; final Uint8List? bytes;
@@ -40,7 +47,9 @@ class MediaViewer extends StatefulWidget {
context: context, context: context,
barrierDismissible: true, barrierDismissible: true,
barrierLabel: 'Schließen', 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), transitionDuration: const Duration(milliseconds: 180),
transitionBuilder: (context, animation, secondaryAnimation, child) => transitionBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child), FadeTransition(opacity: animation, child: child),
@@ -59,21 +68,127 @@ class MediaViewer extends StatefulWidget {
State<MediaViewer> createState() => _MediaViewerState(); 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 _tc = TransformationController();
final _contentKey = GlobalKey();
late final AnimationController _zoomAnim;
Animation<Matrix4>? _zoomTween;
bool _controlsVisible = true; bool _controlsVisible = true;
bool _saving = false; bool _saving = false;
bool _savingSticker = 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 @override
void dispose() { void dispose() {
_zoomAnim.dispose();
_tc.removeListener(_onTransform);
_tc.dispose(); _tc.dispose();
super.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 _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 { Future<void> _saveAsSticker() async {
final event = widget.event; final event = widget.event;
@@ -127,9 +242,16 @@ class _MediaViewerState extends State<MediaViewer> {
final pt = PyramidTheme.of(context); final pt = PyramidTheme.of(context);
final isVideo = widget.isVideo && widget.bytes != null; final isVideo = widget.isVideo && widget.bytes != null;
final size = MediaQuery.sizeOf(context); 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( final header = AnimatedOpacity(
opacity: _controlsVisible ? 1 : 0, opacity: _controlsVisible && _dragOffset == 0 ? 1 : 0,
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
child: IgnorePointer( child: IgnorePointer(
ignoring: !_controlsVisible, ignoring: !_controlsVisible,
@@ -146,7 +268,7 @@ class _MediaViewerState extends State<MediaViewer> {
child: Row( child: Row(
children: [ children: [
IconButton( IconButton(
onPressed: () => Navigator.of(context).pop(), onPressed: _close,
icon: const Icon(Icons.close_rounded, color: Colors.white), icon: const Icon(Icons.close_rounded, color: Colors.white),
tooltip: 'Schließen', tooltip: 'Schließen',
), ),
@@ -181,7 +303,7 @@ class _MediaViewerState extends State<MediaViewer> {
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.download_rounded, color: Colors.white), : const Icon(Icons.download_rounded, color: Colors.white),
), ),
if (!isVideo) if (!isVideo && _zoomed)
IconButton( IconButton(
onPressed: _resetZoom, onPressed: _resetZoom,
tooltip: 'Zoom zurücksetzen', tooltip: 'Zoom zurücksetzen',
@@ -194,61 +316,71 @@ class _MediaViewerState extends State<MediaViewer> {
), ),
); );
// Content area — constrained so the transparent Material outside it lets final Widget content;
// 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);
if (isVideo) { if (isVideo) {
return Material( content = GestureDetector(
type: MaterialType.transparency, 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: Center(
child: SizedBox( key: _contentKey,
width: contentMaxW, // Nur das Bild selbst fängt Tipps ab; daneben greift das Schließen.
height: contentMaxH, child: GestureDetector(
child: Stack( onTap: _toggle,
fit: StackFit.expand, onDoubleTapDown: (d) => _doubleTapGlobal = d.globalPosition,
children: [ onDoubleTap: _toggleZoom,
FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename), child: img,
Positioned(top: 0, left: 0, right: 0, child: header),
],
),
), ),
), ),
); );
} }
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( return Material(
type: MaterialType.transparency, type: MaterialType.transparency,
child: Center( child: Stack(
child: SizedBox( fit: StackFit.expand,
width: contentMaxW, children: [
height: contentMaxH, // Hintergrund + „daneben tippen schließt“.
child: GestureDetector( GestureDetector(
onTap: _toggle, behavior: HitTestBehavior.opaque,
child: Stack( onTap: _close,
fit: StackFit.expand, child: ColoredBox(color: Colors.black.withValues(alpha: bgAlpha)),
children: [ ),
InteractiveViewer( Padding(
transformationController: _tc, padding: EdgeInsets.all(margin),
minScale: 0.5, child: Transform.translate(
maxScale: 10.0, offset: Offset(0, _dragOffset),
child: Center(child: img), child: GestureDetector(
), // Tipps auf die leere Fläche um das Bild (innerhalb des
Positioned(top: 0, left: 0, right: 0, child: header), // 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 final maxW = constraints.maxWidth.isFinite
? constraints.maxWidth.clamp(80.0, 420.0) ? constraints.maxWidth.clamp(80.0, 420.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, // 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 // so the layout doesn't shift (and yank the scroll position) when the image
// finishes loading. Null when dimensions are absent. // finishes loading. Null when dimensions are absent.
@@ -378,7 +378,7 @@ class _MediaContentState extends ConsumerState<_MediaContent> {
final pt = widget.pt; final pt = widget.pt;
final isSticker = widget.event.type == EventTypes.Sticker; final isSticker = widget.event.type == EventTypes.Sticker;
final maxW = isSticker ? 160.0 : 420.0; final maxW = isSticker ? 160.0 : 420.0;
final maxH = isSticker ? 160.0 : 320.0; final maxH = isSticker ? 160.0 : inlineMediaMaxHeight(context);
return FutureBuilder<_MediaResult>( return FutureBuilder<_MediaResult>(
future: _future, future: _future,
+21 -5
View File
@@ -344,7 +344,18 @@ class _MobileShell extends ConsumerWidget {
body = const RoomsPanel(); 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, canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (!didPop) { if (!didPop) {
@@ -358,13 +369,17 @@ class _MobileShell extends ConsumerWidget {
} }
}, },
child: Scaffold( child: Scaffold(
backgroundColor: pt.bg0, // bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste
// geht nahtlos in sie über.
backgroundColor: pt.bg1,
drawer: Drawer( drawer: Drawer(
width: 80, width: 80,
backgroundColor: pt.bg0, 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: [ children: [
body, body,
if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice) ...[ if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice) ...[
@@ -400,7 +415,8 @@ class _MobileShell extends ConsumerWidget {
child: _NotificationBlockedBanner(), child: _NotificationBlockedBanner(),
), ),
], ],
), )),
),
), ),
); );
} }
+10 -2
View File
@@ -33,8 +33,16 @@ void main() async {
await vod.init(); await vod.init();
// Hide status bar and navigation bar for immersive feel // Status- und Navigationsleiste bleiben sichtbar (edge-to-edge): die App
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); // 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())); runApp(const ProviderScope(child: PyramidBootstrap()));
} }
+39
View File
@@ -350,6 +350,11 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons: flutter_launcher_icons:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -517,6 +522,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob: glob:
dependency: transitive dependency: transitive
description: description:
@@ -581,6 +591,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.8.0" version: "4.8.0"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1005,6 +1020,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.6.0" version: "3.6.0"
process:
dependency: transitive
description:
name: process
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
url: "https://pub.dev"
source: hosted
version: "5.0.6"
protobuf: protobuf:
dependency: transitive dependency: transitive
description: description:
@@ -1274,6 +1297,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
@@ -1515,6 +1546,14 @@ packages:
url: "https://github.com/google/webcrypto.dart.git" url: "https://github.com/google/webcrypto.dart.git"
source: git source: git
version: "0.6.0" version: "0.6.0"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
webrtc_interface: webrtc_interface:
dependency: "direct main" dependency: "direct main"
description: description:
+3
View File
@@ -82,6 +82,9 @@ dependencies:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
# UI-Durchläufe mit der echten App (integration_test/), Teil des Flutter-SDKs.
integration_test:
sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.13.1 flutter_launcher_icons: ^0.13.1
# Nur für Tests: offizieller Weg, path_provider ohne echte Plattform zu # Nur für Tests: offizieller Weg, path_provider ohne echte Plattform zu
+24 -1
View File
@@ -5,6 +5,8 @@
# pwsh scripts/test.ps1 -Live + Live-Test gegen steggi-matrix.work # pwsh scripts/test.ps1 -Live + Live-Test gegen steggi-matrix.work
# (Test-Accounts pyramidtest1/2, NICHT Bernd/Uta) # (Test-Accounts pyramidtest1/2, NICHT Bernd/Uta)
# pwsh scripts/test.ps1 -Bench + Benchmarks (Datenbank/Verschlüsselung, Migration) # pwsh scripts/test.ps1 -Bench + Benchmarks (Datenbank/Verschlüsselung, Migration)
# pwsh scripts/test.ps1 -Ui + UI-Durchlauf im Handy-Format mit Screenshots
# (echte App, Testprofil pyramidtest1 → build/ui_screens/)
# pwsh scripts/test.ps1 -All alles zusammen # pwsh scripts/test.ps1 -All alles zusammen
# pwsh scripts/test.ps1 -Bench -UpdateBaseline # pwsh scripts/test.ps1 -Bench -UpdateBaseline
# Benchmark-Werte als neue Baseline speichern # Benchmark-Werte als neue Baseline speichern
@@ -16,6 +18,7 @@
param( param(
[switch]$Live, [switch]$Live,
[switch]$Bench, [switch]$Bench,
[switch]$Ui,
[switch]$All, [switch]$All,
[switch]$UpdateBaseline, [switch]$UpdateBaseline,
[switch]$SkipAnalyze [switch]$SkipAnalyze
@@ -27,7 +30,7 @@ $ErrorActionPreference = "Stop"
# Immer aus dem Projektordner arbeiten, egal von wo das Skript aufgerufen wird. # Immer aus dem Projektordner arbeiten, egal von wo das Skript aufgerufen wird.
Set-Location (Split-Path $PSScriptRoot -Parent) Set-Location (Split-Path $PSScriptRoot -Parent)
if ($All) { $Live = $true; $Bench = $true } if ($All) { $Live = $true; $Bench = $true; $Ui = $true }
$results = [System.Collections.Generic.List[object]]::new() $results = [System.Collections.Generic.List[object]]::new()
@@ -99,6 +102,26 @@ if ($Bench) {
Write-Host "Benchmark-Berichte: build/benchmarks/*.json (Baseline: benchmark/baseline/)" Write-Host "Benchmark-Berichte: build/benchmarks/*.json (Baseline: benchmark/baseline/)"
} }
if ($Ui) {
$uiProfile = Join-Path $env:USERPROFILE ".pyramid-autopilot\profile1"
if (-not (Test-Path (Join-Path $uiProfile "pyramid.sqlite"))) {
Add-Skipped "UI-Durchlauf (Handy-Format)" "Testprofil fehlt: $uiProfile"
} elseif (Get-Process pyramid -ErrorAction SilentlyContinue) {
# Nie zwei Matrix-Clients auf derselben DB – und Bernds Fenster nicht stören.
Add-Skipped "UI-Durchlauf (Handy-Format)" "Pyramid läuft gerade – erst beenden"
} else {
$env:PYRAMID_PROFILE_DIR = $uiProfile
try {
Invoke-Step "UI-Durchlauf (Handy-Format, Testprofil)" {
flutter test integration_test/mobile_ui_screens_test.dart -d windows
}
} finally {
Remove-Item Env:PYRAMID_PROFILE_DIR -ErrorAction SilentlyContinue
}
Write-Host "Screenshots: build/ui_screens/ (Testinhalte ggf. mit test/live_ui_fixture_test.dart anlegen)"
}
}
Write-Host "" Write-Host ""
Write-Host "━━ Zusammenfassung" -ForegroundColor Cyan Write-Host "━━ Zusammenfassung" -ForegroundColor Cyan
$results | Format-Table -AutoSize | Out-String | Write-Host $results | Format-Table -AutoSize | Out-String | Write-Host
+218
View File
@@ -0,0 +1,218 @@
@TestOn('windows')
library;
import 'dart:ffi';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:matrix/matrix.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:sqlite3/open.dart' as sqlite_open;
/// Setup-Helfer für UI-Prüfungen (gegated wie die anderen Live-Tests, läuft
/// NIE im normalen `flutter test`): pyramidtest2 schickt pyramidtest1 eine
/// Direktnachricht mit typischem Chat-Inhalt – langer Text,
/// Antwort, Hochkant- und Querformat-Bild. Danach zeigt die GUI-Instanz mit
/// dem Testprofil (PYRAMID_PROFILE_DIR=…\profile1) echte Inhalte, an denen
/// sich Layout, Bildgrößen und Vollansicht prüfen lassen.
///
/// $env:PYRAMID_LIVE_TEST=1; flutter test test/live_ui_fixture_test.dart
///
/// Der Raum ist unverschlüsselt (der Host-Test hat kein vodozemac). Nur die
/// Skript-Geräte melden sich am Ende ab, GUI-Sessions bleiben unberührt.
Map<String, String>? _loadAccounts() {
final override = Platform.environment['PYRAMID_TEST_ACCOUNTS'];
final profile = Platform.environment['USERPROFILE'] ?? '';
final file = File(
override ?? '$profile\\.pyramid-autopilot\\test-accounts.txt',
);
if (!file.existsSync()) return null;
final map = <String, String>{};
for (final line in file.readAsLinesSync()) {
final t = line.trim();
if (t.isEmpty || t.startsWith('#')) continue;
final i = t.indexOf('=');
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
}
return map;
}
String? _findSqliteDll() {
for (final config in ['Debug', 'Profile', 'Release']) {
final file = File('build/windows/x64/runner/$config/sqlite3.dll');
if (file.existsSync()) return file.absolute.path;
}
return null;
}
Future<Client> _login(String name, String user, String pass, Uri hs) async {
final dll = _findSqliteDll()!;
final factory = createDatabaseFactoryFfi(
ffiInit: () {
sqlite_open.open.overrideFor(
sqlite_open.OperatingSystem.windows,
() => DynamicLibrary.open(dll),
);
},
noIsolate: true,
);
final db = await factory.openDatabase(inMemoryDatabasePath);
final client = Client(
name,
database: await MatrixSdkDatabase.init(name, database: db),
);
await client.init(
waitForFirstSync: false,
waitUntilLoadCompletedLoaded: false,
);
await client.checkHomeserver(hs);
await client.login(
LoginType.mLoginPassword,
identifier: AuthenticationUserIdentifier(user: user),
password: pass,
initialDeviceDisplayName: name,
);
return client;
}
/// Logout ohne verspätete 401 des laufenden Long-Polls (siehe
/// live_encryption_e2e_test.dart, `_logoutWithoutStraySync`).
Future<void> _logout(Client c) async {
try {
c.backgroundSync = false;
await c
.oneShotSync(timeout: Duration.zero)
.timeout(const Duration(seconds: 60));
await c.logout();
} catch (_) {}
}
/// Einfaches, gut erkennbares Testbild (Farbfläche + Kreis + Rahmen).
Uint8List _testImage(int w, int h, img.Color bg) {
final image = img.Image(width: w, height: h);
img.fill(image, color: bg);
img.fillCircle(
image,
x: w ~/ 2,
y: h ~/ 2,
radius: (w < h ? w : h) ~/ 4,
color: img.ColorRgb8(245, 166, 20),
);
img.drawRect(
image,
x1: 0,
y1: 0,
x2: w - 1,
y2: h - 1,
color: img.ColorRgb8(255, 255, 255),
thickness: 6,
);
return Uint8List.fromList(img.encodeJpg(image, quality: 85));
}
void main() {
final acc = _loadAccounts();
final skip = Platform.environment['PYRAMID_LIVE_TEST'] != '1'
? 'Live-Setup nur mit PYRAMID_LIVE_TEST=1'
: acc == null
? 'Test-Account-Datei fehlt'
: _findSqliteDll() == null
? 'SQLite-DLL fehlt (erst flutter build windows)'
: null;
test(
'UI-Testinhalte: DM pyramidtest2 → pyramidtest1 mit Text und Bildern',
() async {
final hs = Uri.parse(acc!['homeserver']!);
final c1 = await _login(
'AutopilotUiFixture1',
acc['user1']!,
acc['pass1']!,
hs,
);
final c2 = await _login(
'AutopilotUiFixture2',
acc['user2']!,
acc['pass2']!,
hs,
);
try {
await c1.oneShotSync().timeout(const Duration(seconds: 60));
await c2.oneShotSync().timeout(const Duration(seconds: 60));
final roomId = await c2.startDirectChat(
c1.userID!,
enableEncryption: false,
preset: CreateRoomPreset.privateChat,
);
final room = c2.getRoomById(roomId)!;
// user1 nimmt die Einladung an (falls noch nicht drin) – über
// room.join(), damit sie wie in der App als Direktnachricht zählt.
await c1.oneShotSync().timeout(const Duration(seconds: 60));
var r1 = c1.getRoomById(roomId);
if (r1 == null || r1.membership != Membership.join) {
if (r1 != null && r1.membership == Membership.invite) {
await r1.join();
} else {
await c1.joinRoom(roomId);
}
await c1
.waitForRoomInSync(roomId, join: true)
.timeout(const Duration(seconds: 60));
r1 = c1.getRoomById(roomId);
}
// „Chats“ in Pyramid zeigt nur Direktnachrichten (m.direct).
if (r1 != null && !r1.isDirectChat) {
await r1.addToDirectChat(c2.userID!);
}
final first = await room.sendTextEvent(
'Hallo! Das ist ein UI-Test mit einer etwas längeren Nachricht, damit '
'man sieht, wie Zeilenumbrüche, Schriftgröße und Abstände auf dem '
'Handy wirken. Umlaute: äöü ÄÖÜ ß.',
);
expect(first, isNotNull);
await c2.oneShotSync().timeout(const Duration(seconds: 30));
final firstEvent = await room.getEventById(first!);
await room.sendTextEvent(
'Kurze Antwort auf die erste Nachricht',
inReplyTo: firstEvent,
);
await room.sendFileEvent(
MatrixImageFile(
bytes: _testImage(720, 1280, img.ColorRgb8(59, 109, 17)),
name: 'hochkant.jpg',
mimeType: 'image/jpeg',
width: 720,
height: 1280,
),
);
await room.sendFileEvent(
MatrixImageFile(
bytes: _testImage(1280, 720, img.ColorRgb8(24, 95, 165)),
name: 'querformat.jpg',
mimeType: 'image/jpeg',
width: 1280,
height: 720,
),
);
await room.sendTextEvent('Und noch eine letzte Nachricht 👋');
// ignore: avoid_print
print('UI-Testraum bereit: $roomId');
} finally {
await _logout(c1);
await _logout(c2);
await c1.dispose(closeDatabase: false);
await c2.dispose(closeDatabase: false);
}
},
skip: skip,
timeout: const Timeout(Duration(minutes: 5)),
);
}