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
+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)),
);
}