diff --git a/docs/TESTS_UND_BENCHMARKS.md b/docs/TESTS_UND_BENCHMARKS.md index 65d0b5d..463de73 100644 --- a/docs/TESTS_UND_BENCHMARKS.md +++ b/docs/TESTS_UND_BENCHMARKS.md @@ -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 -Live # + Live-Test gegen steggi-matrix.work (Test-Account) 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) ``` @@ -40,6 +41,25 @@ Live-Tests: Zugangsdaten liegen AUSSERHALB des Repos (`%USERPROFILE%\.pyramid-autopilot\test-accounts.txt` bzw. `PYRAMID_TEST_ACCOUNTS`). 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/`) Laufen bewusst NICHT im normalen `flutter test` mit (nur `-Bench` bzw. diff --git a/integration_test/mobile_ui_screens_test.dart b/integration_test/mobile_ui_screens_test.dart new file mode 100644 index 0000000..0891784 --- /dev/null +++ b/integration_test/mobile_ui_screens_test.dart @@ -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 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( + 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 _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 _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 _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}'); +} diff --git a/lib/features/chat/media_player.dart b/lib/features/chat/media_player.dart index f13bf81..dbaf48b 100644 --- a/lib/features/chat/media_player.dart +++ b/lib/features/chat/media_player.dart @@ -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 { 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 = >[]; double _speed = 1.0; String _quality = 'Auto'; @@ -452,9 +466,19 @@ class _PyramidVideoPlayerState extends State { 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 _init() async { final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4'; final dir = await getTemporaryDirectory(); @@ -466,6 +490,9 @@ class _PyramidVideoPlayerState extends State { @override void dispose() { + for (final s in _subs) { + s.cancel(); + } _player.dispose(); super.dispose(); } @@ -506,7 +533,10 @@ class _PyramidVideoPlayerState extends State { 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 { ), clipBehavior: Clip.hardEdge, child: AspectRatio( - aspectRatio: 16 / 9, + aspectRatio: _aspect ?? 16 / 9, child: StreamBuilder( stream: _player.stream.playing, builder: (_, playSnap) => StreamBuilder( diff --git a/lib/features/chat/media_viewer.dart b/lib/features/chat/media_viewer.dart index 571bc59..153d92c 100644 --- a/lib/features/chat/media_viewer.dart +++ b/lib/features/chat/media_viewer.dart @@ -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 createState() => _MediaViewerState(); } -class _MediaViewerState extends State { +class _MediaViewerState extends State + 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? _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 _saveAsSticker() async { final event = widget.event; @@ -127,9 +242,16 @@ class _MediaViewerState extends State { 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 { 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 { 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 { ), ); - // 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), + ], ), ); } diff --git a/lib/features/chat/message/message_media.dart b/lib/features/chat/message/message_media.dart index 9cdd8bb..507dcc6 100644 --- a/lib/features/chat/message/message_media.dart +++ b/lib/features/chat/message/message_media.dart @@ -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, diff --git a/lib/layout/app_shell.dart b/lib/layout/app_shell.dart index d44dd71..d81a5d5 100644 --- a/lib/layout/app_shell.dart +++ b/lib/layout/app_shell.dart @@ -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( + 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(), ), ], - ), + )), + ), ), ); } diff --git a/lib/main.dart b/lib/main.dart index 850a682..908c14b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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())); } diff --git a/pubspec.lock b/pubspec.lock index ce1269e..058ab30 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -350,6 +350,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -517,6 +522,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -581,6 +591,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -1005,6 +1020,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.6.0" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" protobuf: dependency: transitive description: @@ -1274,6 +1297,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -1515,6 +1546,14 @@ packages: url: "https://github.com/google/webcrypto.dart.git" source: git version: "0.6.0" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.dev" + source: hosted + version: "3.2.0" webrtc_interface: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 438425a..be1763e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -82,6 +82,9 @@ dependencies: dev_dependencies: flutter_test: 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_launcher_icons: ^0.13.1 # Nur für Tests: offizieller Weg, path_provider ohne echte Plattform zu diff --git a/scripts/test.ps1 b/scripts/test.ps1 index deb51aa..606f09f 100644 --- a/scripts/test.ps1 +++ b/scripts/test.ps1 @@ -5,6 +5,8 @@ # pwsh scripts/test.ps1 -Live + Live-Test gegen steggi-matrix.work # (Test-Accounts pyramidtest1/2, NICHT Bernd/Uta) # 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 -Bench -UpdateBaseline # Benchmark-Werte als neue Baseline speichern @@ -16,6 +18,7 @@ param( [switch]$Live, [switch]$Bench, + [switch]$Ui, [switch]$All, [switch]$UpdateBaseline, [switch]$SkipAnalyze @@ -27,7 +30,7 @@ $ErrorActionPreference = "Stop" # Immer aus dem Projektordner arbeiten, egal von wo das Skript aufgerufen wird. 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() @@ -99,6 +102,26 @@ if ($Bench) { 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 "━━ Zusammenfassung" -ForegroundColor Cyan $results | Format-Table -AutoSize | Out-String | Write-Host diff --git a/test/live_ui_fixture_test.dart b/test/live_ui_fixture_test.dart new file mode 100644 index 0000000..86241f0 --- /dev/null +++ b/test/live_ui_fixture_test.dart @@ -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? _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 = {}; + 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 _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 _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)), + ); +}