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
@@ -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.
|
||||
|
||||
@@ -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}');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+24
-1
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user