Files
pyramid/integration_test/mobile_ui_screens_test.dart
T
Bernd SteckmeisterandClaude Opus 5.5 382747cdb1 feat: Handy - groessere Symbole, Suche/Mitglieder im Menue, Kontakt-Banner repariert, Schrift 110 %, federnde Knoepfe
Bernds Feedback 2026-10-07:
- Schrift-Standard Handy 110 % (statt 115 %).
- PyrIconBtn: im Handy-Layout Symbol 22 px (vorher fest 16), Flaeche 40,
  Tippflaeche 44; Farben aus dem Farbmodus statt fest verdrahtet.
- Eingabeleiste: Anhaengen/GIF/Emoji/Senden auf dem Handy 22-23 px statt
  12-13 px.
- Chat-Kopf (Handy): Anrufen + Videoanruf sichtbar, Suchen + Mitglieder im
  Drei-Punkte-Menue.
- Aufklappbarer DM-Kopf als Ebenen: Zurueck + Symbole bleiben oben,
  Profilbereich gleitet darunter und nutzt die volle Breite (vorher Name zu
  "p..." gekuerzt, Symbole rutschten in die Mitte); Kuerzel im Avatar
  mittig, skaliert mit.
- SpringPress (widgets/spring_press.dart): federnde Druck-Rueckmeldung,
  aus bei "Animationen 0 %".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 18:13:32 +02:00

266 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.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 + Gruppe mit pyramidtest2).
///
/// $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). Ansichten (Darstellung, Farbmodus, Raum)
/// werden nur über den App-Zustand umgeschaltet – nichts wird gespeichert.
///
/// 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';
const _groupName = 'UI-Test Gruppe';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
testWidgets(
'Handy-UI: Chatliste, Chat, Bild-Vollansicht, Gruppe, Einstellungen',
(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);
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
app.main();
await _pumpUntil(
tester,
find.text(_otherUser),
timeout: const Duration(seconds: 90),
);
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
await _shot(tester, '01_chatliste');
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
await tester.tap(find.text(_otherUser).first);
await _pumpFor(tester, const Duration(seconds: 6));
await _scrollToNewest(tester);
await _shot(tester, '02_chat');
// Kontakt-Banner aufklappen (Tipp auf den Namen im Kopf) und wieder zu.
await tester.tap(find.text(_otherUser).first);
await _pumpFor(tester, const Duration(seconds: 1));
await _shot(tester, '02c_dm_kopf_aufgeklappt');
await tester.tap(find.text(_otherUser).first);
await _pumpFor(tester, const Duration(seconds: 1));
// ⋮-Menü mit Suchen/Mitglieder.
await tester.tap(find.byIcon(Icons.more_vert_rounded).first);
await _pumpFor(tester, const Duration(seconds: 1));
expect(find.text('Mitglieder'), findsWidgets);
await _shot(tester, '02d_menue');
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await _pumpFor(tester, const Duration(seconds: 1));
container.read(messageStyleProvider.notifier).state = MessageStyle.list;
await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '02b_chat_liste');
container.read(messageStyleProvider.notifier).state = MessageStyle.auto;
await _pumpFor(tester, const Duration(seconds: 2));
// ── Bild-Vollansicht ──
// 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 Bild) schließt.
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, nochmal doppelt wieder heraus.
Future<void> doubleTapCenter() async {
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));
}
await doubleTapCenter();
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');
await doubleTapCenter();
// 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');
// ── Farbmodus Hell (nur Zustand) ──
final savedVariant = container.read(themeVariantProvider);
container.read(themeVariantProvider.notifier).state = ThemeVariant.light;
await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '07_chat_hell');
container.read(themeVariantProvider.notifier).state = savedVariant;
await _pumpFor(tester, const Duration(seconds: 1));
// ── Gruppenraum (Namen + Avatare in den Sprechblasen) ──
container.read(activeRoomIdProvider.notifier).state = null;
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
await _pumpUntil(
tester,
find.text(_groupName),
timeout: const Duration(seconds: 30),
);
await tester.tap(find.text(_groupName).first);
await _pumpFor(tester, const Duration(seconds: 5));
await _scrollToNewest(tester);
await _shot(tester, '08_gruppe');
// ── Einstellungen → Erscheinungsbild ──
container.read(activeModalProvider.notifier).state = ModalKind.settings;
await _pumpFor(tester, const Duration(seconds: 2));
await tester.tap(find.text('Erscheinungsbild').first);
await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '09_einstellungen_erscheinungsbild');
await tester.drag(
find.text('Schriftgröße').first,
const Offset(0, -500),
warnIfMissed: false,
);
await _pumpFor(tester, const Duration(seconds: 1));
await _shot(tester, '10_einstellungen_text_chat');
container.read(activeModalProvider.notifier).state = ModalKind.none;
await _pumpFor(tester, const Duration(seconds: 1));
},
skip: profile == null,
timeout: const Timeout(Duration(minutes: 6)),
);
}
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);
}
/// Ganz nach unten zu den neuesten Nachrichten scrollen.
Future<void> _scrollToNewest(WidgetTester tester) async {
for (var i = 0; i < 6; i++) {
final lists = find.byType(Scrollable);
if (lists.evaluate().isEmpty) return;
await tester.fling(lists.first, const Offset(0, -1500), 3000,
warnIfMissed: false);
await _pumpFor(tester, const Duration(milliseconds: 500));
}
await _pumpFor(tester, const Duration(seconds: 2));
}
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}');
}