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 # Analyse + Unit-Tests (~1 min) – vor jedem Commit
|
||||||
pwsh scripts/test.ps1 -Live # + Live-Test gegen steggi-matrix.work (Test-Account)
|
pwsh scripts/test.ps1 -Live # + Live-Test gegen steggi-matrix.work (Test-Account)
|
||||||
pwsh scripts/test.ps1 -Bench # + Benchmarks (Datenbank/Verschlüsselung, Migration)
|
pwsh scripts/test.ps1 -Bench # + Benchmarks (Datenbank/Verschlüsselung, Migration)
|
||||||
|
pwsh scripts/test.ps1 -Ui # + UI-Durchlauf im Handy-Format mit Screenshots
|
||||||
pwsh scripts/test.ps1 -All # alles (~4 min)
|
pwsh scripts/test.ps1 -All # alles (~4 min)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -40,6 +41,25 @@ Live-Tests: Zugangsdaten liegen AUSSERHALB des Repos
|
|||||||
(`%USERPROFILE%\.pyramid-autopilot\test-accounts.txt` bzw. `PYRAMID_TEST_ACCOUNTS`).
|
(`%USERPROFILE%\.pyramid-autopilot\test-accounts.txt` bzw. `PYRAMID_TEST_ACCOUNTS`).
|
||||||
Es sind die Test-Accounts `pyramidtest1/2`, nie Bernds oder Utas Konten.
|
Es sind die Test-Accounts `pyramidtest1/2`, nie Bernds oder Utas Konten.
|
||||||
|
|
||||||
|
## UI-Durchlauf (`integration_test/`, seit 2026-10-07)
|
||||||
|
|
||||||
|
`integration_test/mobile_ui_screens_test.dart` startet die ECHTE App
|
||||||
|
(Windows-Build) mit dem Testprofil `pyramidtest1`
|
||||||
|
(`PYRAMID_PROFILE_DIR=%USERPROFILE%\.pyramid-autopilot\profile1`) in
|
||||||
|
simulierter Handy-Größe (1080×2340, Statusleiste 24 dp, Gestenleiste 16 dp),
|
||||||
|
tippt sich durch Chatliste → Chat → Bild und prüft dabei: Vollansicht öffnet,
|
||||||
|
Tippen daneben schließt, Tippen aufs Bild schließt nicht, Doppeltipp zoomt,
|
||||||
|
Wischen nach unten schließt. Screenshots landen in `build/ui_screens/` – damit
|
||||||
|
lassen sich UI-Änderungen ohne Handy ansehen.
|
||||||
|
|
||||||
|
- Testinhalte (DM von pyramidtest2 mit langem Text, Antwort, Hoch- und
|
||||||
|
Querformat-Bild) legt `test/live_ui_fixture_test.dart` an
|
||||||
|
(`PYRAMID_LIVE_TEST=1`).
|
||||||
|
- Läuft nur, wenn keine Pyramid-Instanz offen ist (nie zwei Clients auf
|
||||||
|
einer Datenbank) – das Skript prüft das.
|
||||||
|
- Simulierte Fensterklicks (PostMessage) erreichen Flutter unter Windows
|
||||||
|
NICHT; deshalb dieser Weg über `integration_test`.
|
||||||
|
|
||||||
## Benchmarks (`benchmark/`)
|
## Benchmarks (`benchmark/`)
|
||||||
|
|
||||||
Laufen bewusst NICHT im normalen `flutter test` mit (nur `-Bench` bzw.
|
Laufen bewusst NICHT im normalen `flutter test` mit (nur `-Bench` bzw.
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:pyramid/main.dart' as app;
|
||||||
|
|
||||||
|
/// UI-Durchlauf im HANDY-Format mit der echten App (Windows-Build) und einem
|
||||||
|
/// TEST-Profil – erzeugt Screenshots unter build/ui_screens/.
|
||||||
|
///
|
||||||
|
/// Läuft nur mit gesetztem PYRAMID_PROFILE_DIR (eingeloggtes Testprofil, z. B.
|
||||||
|
/// pyramidtest1 unter %USERPROFILE%\.pyramid-autopilot\profile1) – nie gegen
|
||||||
|
/// Bernds echtes Profil. Testinhalte vorher mit
|
||||||
|
/// `test/live_ui_fixture_test.dart` anlegen (DM von pyramidtest2 mit Bildern).
|
||||||
|
///
|
||||||
|
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||||
|
/// flutter test integration_test/mobile_ui_screens_test.dart -d windows
|
||||||
|
///
|
||||||
|
/// Achtung: Keine zweite Pyramid-Instanz auf demselben Profil laufen lassen
|
||||||
|
/// (zwei Clients auf einer olm-DB).
|
||||||
|
///
|
||||||
|
/// Simuliert wird ein typisches Handy: 1080×2340 px, Dichte 2,625
|
||||||
|
/// (≈ 411×891 dp), Statusleiste oben 24 dp, Gestenleiste unten 16 dp.
|
||||||
|
const _otherUser = 'pyramidtest2';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Handy-UI: Chatliste, Chat, Bild-Vollansicht',
|
||||||
|
(tester) async {
|
||||||
|
tester.view
|
||||||
|
..physicalSize = const Size(1080, 2340)
|
||||||
|
..devicePixelRatio = 2.625
|
||||||
|
..padding = const FakeViewPadding(top: 63, bottom: 42)
|
||||||
|
..viewPadding = const FakeViewPadding(top: 63, bottom: 42);
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
app.main();
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.text(_otherUser),
|
||||||
|
timeout: const Duration(seconds: 90),
|
||||||
|
);
|
||||||
|
await _shot(tester, '01_chatliste');
|
||||||
|
|
||||||
|
await tester.tap(find.text(_otherUser).first);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 6));
|
||||||
|
await _shot(tester, '02_chat');
|
||||||
|
|
||||||
|
// Größtes Bild im Chat = eines der Testbilder; erst sichtbar scrollen.
|
||||||
|
Future<void> openViewer() async {
|
||||||
|
final image = _largestImage(tester);
|
||||||
|
expect(image, isNotNull, reason: 'kein Bild im Test-Chat gefunden');
|
||||||
|
await tester.ensureVisible(image!);
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 600));
|
||||||
|
await tester.tap(image);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
expect(
|
||||||
|
find.byType(InteractiveViewer),
|
||||||
|
findsOneWidget,
|
||||||
|
reason: 'Antippen des Bildes hat die Vollansicht nicht geöffnet',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await openViewer();
|
||||||
|
await _shot(tester, '03_vollansicht');
|
||||||
|
|
||||||
|
// Tippen NEBEN das Bild (ganz unten, unter dem Hochkant-Bild) schließt.
|
||||||
|
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
|
||||||
|
await tester.tapAt(Offset(size.width / 2, size.height - 30));
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, '04_nach_tippen_daneben');
|
||||||
|
expect(
|
||||||
|
find.byType(InteractiveViewer),
|
||||||
|
findsNothing,
|
||||||
|
reason: 'Tippen neben das Bild hat die Vollansicht nicht geschlossen',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tippen AUFS Bild schließt NICHT (blendet nur die Leiste aus).
|
||||||
|
await openViewer();
|
||||||
|
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
expect(
|
||||||
|
find.byType(InteractiveViewer),
|
||||||
|
findsOneWidget,
|
||||||
|
reason: 'Tippen aufs Bild darf die Vollansicht nicht schließen',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Doppeltipp zoomt hinein.
|
||||||
|
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||||||
|
await tester.pump(const Duration(milliseconds: 80));
|
||||||
|
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
final viewer = tester.widget<InteractiveViewer>(
|
||||||
|
find.byType(InteractiveViewer),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
viewer.transformationController!.value.getMaxScaleOnAxis(),
|
||||||
|
greaterThan(1.5),
|
||||||
|
reason: 'Doppeltipp hat nicht gezoomt',
|
||||||
|
);
|
||||||
|
await _shot(tester, '05_doppeltipp_zoom');
|
||||||
|
// Nochmal doppelt → zurück auf 1.
|
||||||
|
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||||||
|
await tester.pump(const Duration(milliseconds: 80));
|
||||||
|
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
|
||||||
|
// Wischen nach unten schließt.
|
||||||
|
await tester.dragFrom(
|
||||||
|
Offset(size.width / 2, size.height / 2),
|
||||||
|
const Offset(0, 320),
|
||||||
|
);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
expect(
|
||||||
|
find.byType(InteractiveViewer),
|
||||||
|
findsNothing,
|
||||||
|
reason: 'Wischen nach unten hat die Vollansicht nicht geschlossen',
|
||||||
|
);
|
||||||
|
await _shot(tester, '06_nach_wischen');
|
||||||
|
},
|
||||||
|
skip: profile == null,
|
||||||
|
timeout: const Timeout(Duration(minutes: 5)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Finder? _largestImage(WidgetTester tester) {
|
||||||
|
Element? best;
|
||||||
|
var bestArea = 150.0 * 150.0;
|
||||||
|
for (final element in find.byType(Image).evaluate()) {
|
||||||
|
final box = element.renderObject;
|
||||||
|
if (box is! RenderBox || !box.hasSize) continue;
|
||||||
|
final area = box.size.width * box.size.height;
|
||||||
|
if (area > bestArea) {
|
||||||
|
bestArea = area;
|
||||||
|
best = element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best == null ? null : find.byElementPredicate((e) => e == best);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpFor(WidgetTester tester, Duration duration) async {
|
||||||
|
final end = DateTime.now().add(duration);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpUntil(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder finder, {
|
||||||
|
required Duration timeout,
|
||||||
|
}) async {
|
||||||
|
final end = DateTime.now().add(timeout);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
if (finder.evaluate().isNotEmpty) return;
|
||||||
|
}
|
||||||
|
fail('Zeitüberschreitung: $finder nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Speichert, was gerade auf dem (simulierten) Bildschirm ist.
|
||||||
|
Future<void> _shot(WidgetTester tester, String name) async {
|
||||||
|
await tester.pump();
|
||||||
|
final renderView = tester.binding.renderViews.first;
|
||||||
|
final layer = renderView.debugLayer! as OffsetLayer;
|
||||||
|
final physical = tester.view.physicalSize;
|
||||||
|
final image = await tester.binding.runAsync(
|
||||||
|
() => layer.toImage(Offset.zero & physical),
|
||||||
|
);
|
||||||
|
final bytes = await tester.binding.runAsync(
|
||||||
|
() => image!.toByteData(format: ui.ImageByteFormat.png),
|
||||||
|
);
|
||||||
|
final file = File('build/ui_screens/$name.png');
|
||||||
|
file.parent.createSync(recursive: true);
|
||||||
|
file.writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('Screenshot: ${file.path}');
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
@@ -419,6 +420,15 @@ class _ChunkyBarsPainter extends CustomPainter {
|
|||||||
//
|
//
|
||||||
// Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide)
|
// Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide)
|
||||||
|
|
||||||
|
/// Maximale Höhe für Bilder/Videos im Chat. Auf dem Handy bis zur halben
|
||||||
|
/// Bildschirmhöhe – vorher galten überall 320 px, wodurch Hochkant-Fotos auf
|
||||||
|
/// dem Handy nur ~180 px schmal waren (Bernds Feedback 2026-10-07).
|
||||||
|
double inlineMediaMaxHeight(BuildContext context) {
|
||||||
|
final screen = MediaQuery.sizeOf(context);
|
||||||
|
final isMobile = screen.width < 900 || screen.height < 500;
|
||||||
|
return isMobile ? (screen.height * 0.5).clamp(240.0, 560.0) : 320.0;
|
||||||
|
}
|
||||||
|
|
||||||
class PyramidVideoPlayer extends StatefulWidget {
|
class PyramidVideoPlayer extends StatefulWidget {
|
||||||
final Uint8List bytes;
|
final Uint8List bytes;
|
||||||
final String filename;
|
final String filename;
|
||||||
@@ -441,6 +451,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
|||||||
late final Player _player;
|
late final Player _player;
|
||||||
late final VideoController _controller;
|
late final VideoController _controller;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
// Echtes Seitenverhältnis, sobald das Video seine Größe kennt (vorher fest
|
||||||
|
// 16:9 → Hochkant-Handyvideos klein zwischen schwarzen Balken).
|
||||||
|
double? _aspect;
|
||||||
|
final _subs = <StreamSubscription<int?>>[];
|
||||||
double _speed = 1.0;
|
double _speed = 1.0;
|
||||||
String _quality = 'Auto';
|
String _quality = 'Auto';
|
||||||
|
|
||||||
@@ -452,9 +466,19 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_player = Player();
|
_player = Player();
|
||||||
_controller = VideoController(_player);
|
_controller = VideoController(_player);
|
||||||
|
_subs.add(_player.stream.width.listen((_) => _updateAspect()));
|
||||||
|
_subs.add(_player.stream.height.listen((_) => _updateAspect()));
|
||||||
_init();
|
_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _updateAspect() {
|
||||||
|
final w = _player.state.width;
|
||||||
|
final h = _player.state.height;
|
||||||
|
if (w == null || h == null || w <= 0 || h <= 0) return;
|
||||||
|
final aspect = w / h;
|
||||||
|
if (mounted && aspect != _aspect) setState(() => _aspect = aspect);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4';
|
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4';
|
||||||
final dir = await getTemporaryDirectory();
|
final dir = await getTemporaryDirectory();
|
||||||
@@ -466,6 +490,9 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
for (final s in _subs) {
|
||||||
|
s.cancel();
|
||||||
|
}
|
||||||
_player.dispose();
|
_player.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -506,7 +533,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
|||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(top: 4, bottom: 4),
|
margin: const EdgeInsets.only(top: 4, bottom: 4),
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: 420,
|
||||||
|
maxHeight: inlineMediaMaxHeight(context),
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
borderRadius: BorderRadius.circular(pt.rLg),
|
borderRadius: BorderRadius.circular(pt.rLg),
|
||||||
@@ -514,7 +544,7 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
|||||||
),
|
),
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: _aspect ?? 16 / 9,
|
||||||
child: StreamBuilder<bool>(
|
child: StreamBuilder<bool>(
|
||||||
stream: _player.stream.playing,
|
stream: _player.stream.playing,
|
||||||
builder: (_, playSnap) => StreamBuilder<Duration>(
|
builder: (_, playSnap) => StreamBuilder<Duration>(
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import 'package:pyramid/features/chat/media_player.dart';
|
|||||||
import 'package:pyramid/utils/gif_favorite_service.dart';
|
import 'package:pyramid/utils/gif_favorite_service.dart';
|
||||||
|
|
||||||
/// Overlay viewer for images, GIFs and videos.
|
/// Overlay viewer for images, GIFs and videos.
|
||||||
/// Uses a semi-transparent backdrop; tapping outside the content closes it.
|
///
|
||||||
|
/// Bedienung (Bernds Feedback 2026-10-07, wie man es von WhatsApp & Co. kennt):
|
||||||
|
/// * Handy: randlos auf schwarzem Grund (vorher 24-px-Rand, halbdurchsichtig).
|
||||||
|
/// * Tippen NEBEN das Bild schließt, Tippen AUFS Bild blendet die Leiste ein/aus.
|
||||||
|
/// * Nach oben/unten wischen schließt (nur ohne Zoom), der Hintergrund blendet
|
||||||
|
/// dabei aus.
|
||||||
|
/// * Doppeltipp zoomt an der Stelle hinein bzw. wieder heraus; mit zwei
|
||||||
|
/// Fingern aufziehen geht weiterhin.
|
||||||
class MediaViewer extends StatefulWidget {
|
class MediaViewer extends StatefulWidget {
|
||||||
final String senderName;
|
final String senderName;
|
||||||
final Uint8List? bytes;
|
final Uint8List? bytes;
|
||||||
@@ -40,7 +47,9 @@ class MediaViewer extends StatefulWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: true,
|
barrierDismissible: true,
|
||||||
barrierLabel: 'Schließen',
|
barrierLabel: 'Schließen',
|
||||||
barrierColor: Colors.black.withAlpha(178),
|
// Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen
|
||||||
|
// ausblenden kann).
|
||||||
|
barrierColor: Colors.transparent,
|
||||||
transitionDuration: const Duration(milliseconds: 180),
|
transitionDuration: const Duration(milliseconds: 180),
|
||||||
transitionBuilder: (context, animation, secondaryAnimation, child) =>
|
transitionBuilder: (context, animation, secondaryAnimation, child) =>
|
||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(opacity: animation, child: child),
|
||||||
@@ -59,21 +68,127 @@ class MediaViewer extends StatefulWidget {
|
|||||||
State<MediaViewer> createState() => _MediaViewerState();
|
State<MediaViewer> createState() => _MediaViewerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MediaViewerState extends State<MediaViewer> {
|
class _MediaViewerState extends State<MediaViewer>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
/// Ab so viel Pixel Wischweg (oder entsprechend schnell) wird geschlossen.
|
||||||
|
static const _dismissDistance = 120.0;
|
||||||
|
static const _dismissVelocity = 1000.0;
|
||||||
|
static const _doubleTapScale = 2.5;
|
||||||
|
|
||||||
final _tc = TransformationController();
|
final _tc = TransformationController();
|
||||||
|
final _contentKey = GlobalKey();
|
||||||
|
late final AnimationController _zoomAnim;
|
||||||
|
Animation<Matrix4>? _zoomTween;
|
||||||
|
|
||||||
bool _controlsVisible = true;
|
bool _controlsVisible = true;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
bool _savingSticker = false;
|
bool _savingSticker = false;
|
||||||
|
bool _zoomed = false;
|
||||||
|
bool _dragging = false;
|
||||||
|
double _dragOffset = 0;
|
||||||
|
Offset? _doubleTapGlobal;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_zoomAnim = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
)..addListener(() {
|
||||||
|
final tween = _zoomTween;
|
||||||
|
if (tween != null) _tc.value = tween.value;
|
||||||
|
});
|
||||||
|
_tc.addListener(_onTransform);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_zoomAnim.dispose();
|
||||||
|
_tc.removeListener(_onTransform);
|
||||||
_tc.dispose();
|
_tc.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onTransform() {
|
||||||
|
final zoomed = _tc.value.getMaxScaleOnAxis() > 1.01;
|
||||||
|
if (zoomed != _zoomed) setState(() => _zoomed = zoomed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _close() => Navigator.of(context).maybePop();
|
||||||
|
|
||||||
void _toggle() => setState(() => _controlsVisible = !_controlsVisible);
|
void _toggle() => setState(() => _controlsVisible = !_controlsVisible);
|
||||||
|
|
||||||
void _resetZoom() => _tc.value = Matrix4.identity();
|
void _animateTo(Matrix4 target) {
|
||||||
|
_zoomTween = Matrix4Tween(begin: _tc.value, end: target).animate(
|
||||||
|
CurvedAnimation(parent: _zoomAnim, curve: Curves.easeOutCubic),
|
||||||
|
);
|
||||||
|
_zoomAnim.forward(from: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetZoom() => _animateTo(Matrix4.identity());
|
||||||
|
|
||||||
|
/// Doppeltipp: an der getippten Stelle hineinzoomen bzw. ganz heraus.
|
||||||
|
void _toggleZoom() {
|
||||||
|
final global = _doubleTapGlobal;
|
||||||
|
final box = _contentKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (_zoomed || global == null || box == null) {
|
||||||
|
_resetZoom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final p = box.globalToLocal(global);
|
||||||
|
_animateTo(Matrix4.identity()
|
||||||
|
..translateByDouble(
|
||||||
|
-p.dx * (_doubleTapScale - 1),
|
||||||
|
-p.dy * (_doubleTapScale - 1),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
..scaleByDouble(_doubleTapScale, _doubleTapScale, 1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wischen zum Schließen (über die InteractiveViewer-Callbacks, damit es
|
||||||
|
// nicht mit dem Zwei-Finger-Zoom um die Gesten konkurriert) ─────────────
|
||||||
|
|
||||||
|
void _onInteractionStart(ScaleStartDetails d) {
|
||||||
|
_dragging = !_zoomed && d.pointerCount == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onInteractionUpdate(ScaleUpdateDetails d) {
|
||||||
|
if (!_dragging) return;
|
||||||
|
if (d.pointerCount > 1) {
|
||||||
|
// Zweiter Finger dazu → das ist ein Zoom, kein Wischen.
|
||||||
|
setState(() {
|
||||||
|
_dragging = false;
|
||||||
|
_dragOffset = 0;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _dragOffset += d.focalPointDelta.dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onInteractionEnd(ScaleEndDetails d) {
|
||||||
|
if (!_dragging) return;
|
||||||
|
_dragging = false;
|
||||||
|
final fast = d.velocity.pixelsPerSecond.dy.abs() > _dismissVelocity;
|
||||||
|
if (_dragOffset.abs() > _dismissDistance || fast) {
|
||||||
|
_close();
|
||||||
|
} else {
|
||||||
|
setState(() => _dragOffset = 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video: kein InteractiveViewer, also normales vertikales Wischen.
|
||||||
|
void _onVideoDragUpdate(DragUpdateDetails d) =>
|
||||||
|
setState(() => _dragOffset += d.delta.dy);
|
||||||
|
|
||||||
|
void _onVideoDragEnd(DragEndDetails d) {
|
||||||
|
final fast = d.velocity.pixelsPerSecond.dy.abs() > _dismissVelocity;
|
||||||
|
if (_dragOffset.abs() > _dismissDistance || fast) {
|
||||||
|
_close();
|
||||||
|
} else {
|
||||||
|
setState(() => _dragOffset = 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveAsSticker() async {
|
Future<void> _saveAsSticker() async {
|
||||||
final event = widget.event;
|
final event = widget.event;
|
||||||
@@ -127,9 +242,16 @@ class _MediaViewerState extends State<MediaViewer> {
|
|||||||
final pt = PyramidTheme.of(context);
|
final pt = PyramidTheme.of(context);
|
||||||
final isVideo = widget.isVideo && widget.bytes != null;
|
final isVideo = widget.isVideo && widget.bytes != null;
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
final isMobile = size.width < 900 || size.height < 500;
|
||||||
|
|
||||||
|
// Beim Wischen blendet der Hintergrund aus und zeigt, dass gleich zu ist.
|
||||||
|
final dismissProgress = (_dragOffset.abs() / (_dismissDistance * 2.5)).clamp(0.0, 1.0);
|
||||||
|
final bgAlpha = (isMobile ? 1.0 : 0.82) * (1 - dismissProgress);
|
||||||
|
// Handy: randlos; Desktop: etwas Luft, damit das Fenster dahinter erkennbar bleibt.
|
||||||
|
final margin = isMobile ? 0.0 : 24.0;
|
||||||
|
|
||||||
final header = AnimatedOpacity(
|
final header = AnimatedOpacity(
|
||||||
opacity: _controlsVisible ? 1 : 0,
|
opacity: _controlsVisible && _dragOffset == 0 ? 1 : 0,
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: !_controlsVisible,
|
ignoring: !_controlsVisible,
|
||||||
@@ -146,7 +268,7 @@ class _MediaViewerState extends State<MediaViewer> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: _close,
|
||||||
icon: const Icon(Icons.close_rounded, color: Colors.white),
|
icon: const Icon(Icons.close_rounded, color: Colors.white),
|
||||||
tooltip: 'Schließen',
|
tooltip: 'Schließen',
|
||||||
),
|
),
|
||||||
@@ -181,7 +303,7 @@ class _MediaViewerState extends State<MediaViewer> {
|
|||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||||
: const Icon(Icons.download_rounded, color: Colors.white),
|
: const Icon(Icons.download_rounded, color: Colors.white),
|
||||||
),
|
),
|
||||||
if (!isVideo)
|
if (!isVideo && _zoomed)
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _resetZoom,
|
onPressed: _resetZoom,
|
||||||
tooltip: 'Zoom zurücksetzen',
|
tooltip: 'Zoom zurücksetzen',
|
||||||
@@ -194,61 +316,71 @@ class _MediaViewerState extends State<MediaViewer> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Content area — constrained so the transparent Material outside it lets
|
final Widget content;
|
||||||
// pointer events through to the barrier (which dismisses on tap).
|
|
||||||
final contentMaxW = (size.width - 48).clamp(200.0, 1200.0);
|
|
||||||
final contentMaxH = (size.height - 48).clamp(200.0, 900.0);
|
|
||||||
|
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
return Material(
|
content = GestureDetector(
|
||||||
type: MaterialType.transparency,
|
onVerticalDragUpdate: _onVideoDragUpdate,
|
||||||
|
onVerticalDragEnd: _onVideoDragEnd,
|
||||||
|
child: FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Widget img;
|
||||||
|
if (widget.bytes != null) {
|
||||||
|
img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true);
|
||||||
|
} else if (widget.networkUrl != null) {
|
||||||
|
img = Image.network(widget.networkUrl!, fit: BoxFit.contain);
|
||||||
|
} else {
|
||||||
|
img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim);
|
||||||
|
}
|
||||||
|
content = InteractiveViewer(
|
||||||
|
transformationController: _tc,
|
||||||
|
minScale: 1.0,
|
||||||
|
maxScale: 10.0,
|
||||||
|
// Ohne Zoom gibt es nichts zu verschieben – die Ein-Finger-Bewegung
|
||||||
|
// gehört dann dem Wischen zum Schließen.
|
||||||
|
panEnabled: _zoomed,
|
||||||
|
onInteractionStart: _onInteractionStart,
|
||||||
|
onInteractionUpdate: _onInteractionUpdate,
|
||||||
|
onInteractionEnd: _onInteractionEnd,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SizedBox(
|
key: _contentKey,
|
||||||
width: contentMaxW,
|
// Nur das Bild selbst fängt Tipps ab; daneben greift das Schließen.
|
||||||
height: contentMaxH,
|
child: GestureDetector(
|
||||||
child: Stack(
|
onTap: _toggle,
|
||||||
fit: StackFit.expand,
|
onDoubleTapDown: (d) => _doubleTapGlobal = d.globalPosition,
|
||||||
children: [
|
onDoubleTap: _toggleZoom,
|
||||||
FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename),
|
child: img,
|
||||||
Positioned(top: 0, left: 0, right: 0, child: header),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget img;
|
|
||||||
if (widget.bytes != null) {
|
|
||||||
img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true);
|
|
||||||
} else if (widget.networkUrl != null) {
|
|
||||||
img = Image.network(widget.networkUrl!, fit: BoxFit.contain);
|
|
||||||
} else {
|
|
||||||
img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
type: MaterialType.transparency,
|
type: MaterialType.transparency,
|
||||||
child: Center(
|
child: Stack(
|
||||||
child: SizedBox(
|
fit: StackFit.expand,
|
||||||
width: contentMaxW,
|
children: [
|
||||||
height: contentMaxH,
|
// Hintergrund + „daneben tippen schließt“.
|
||||||
child: GestureDetector(
|
GestureDetector(
|
||||||
onTap: _toggle,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: Stack(
|
onTap: _close,
|
||||||
fit: StackFit.expand,
|
child: ColoredBox(color: Colors.black.withValues(alpha: bgAlpha)),
|
||||||
children: [
|
),
|
||||||
InteractiveViewer(
|
Padding(
|
||||||
transformationController: _tc,
|
padding: EdgeInsets.all(margin),
|
||||||
minScale: 0.5,
|
child: Transform.translate(
|
||||||
maxScale: 10.0,
|
offset: Offset(0, _dragOffset),
|
||||||
child: Center(child: img),
|
child: GestureDetector(
|
||||||
),
|
// Tipps auf die leere Fläche um das Bild (innerhalb des
|
||||||
Positioned(top: 0, left: 0, right: 0, child: header),
|
// Inhaltsbereichs) schließen ebenfalls.
|
||||||
],
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTap: _close,
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Positioned(top: 0, left: 0, right: 0, child: header),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class _ImageContentState extends ConsumerState<_ImageContent> {
|
|||||||
final maxW = constraints.maxWidth.isFinite
|
final maxW = constraints.maxWidth.isFinite
|
||||||
? constraints.maxWidth.clamp(80.0, 420.0)
|
? constraints.maxWidth.clamp(80.0, 420.0)
|
||||||
: 420.0;
|
: 420.0;
|
||||||
const maxH = 320.0;
|
final maxH = inlineMediaMaxHeight(context);
|
||||||
// Reserve the final display box up-front from the event's info dimensions,
|
// Reserve the final display box up-front from the event's info dimensions,
|
||||||
// so the layout doesn't shift (and yank the scroll position) when the image
|
// so the layout doesn't shift (and yank the scroll position) when the image
|
||||||
// finishes loading. Null when dimensions are absent.
|
// finishes loading. Null when dimensions are absent.
|
||||||
@@ -378,7 +378,7 @@ class _MediaContentState extends ConsumerState<_MediaContent> {
|
|||||||
final pt = widget.pt;
|
final pt = widget.pt;
|
||||||
final isSticker = widget.event.type == EventTypes.Sticker;
|
final isSticker = widget.event.type == EventTypes.Sticker;
|
||||||
final maxW = isSticker ? 160.0 : 420.0;
|
final maxW = isSticker ? 160.0 : 420.0;
|
||||||
final maxH = isSticker ? 160.0 : 320.0;
|
final maxH = isSticker ? 160.0 : inlineMediaMaxHeight(context);
|
||||||
|
|
||||||
return FutureBuilder<_MediaResult>(
|
return FutureBuilder<_MediaResult>(
|
||||||
future: _future,
|
future: _future,
|
||||||
|
|||||||
@@ -344,7 +344,18 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
body = const RoomsPanel();
|
body = const RoomsPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
return PopScope(
|
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
|
||||||
|
final overlayStyle = (pt.isDark
|
||||||
|
? SystemUiOverlayStyle.light
|
||||||
|
: SystemUiOverlayStyle.dark)
|
||||||
|
.copyWith(
|
||||||
|
statusBarColor: Colors.transparent,
|
||||||
|
systemNavigationBarColor: Colors.transparent,
|
||||||
|
);
|
||||||
|
|
||||||
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
|
value: overlayStyle,
|
||||||
|
child: PopScope(
|
||||||
canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen,
|
canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
if (!didPop) {
|
if (!didPop) {
|
||||||
@@ -358,13 +369,17 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: pt.bg0,
|
// bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste
|
||||||
|
// geht nahtlos in sie über.
|
||||||
|
backgroundColor: pt.bg1,
|
||||||
drawer: Drawer(
|
drawer: Drawer(
|
||||||
width: 80,
|
width: 80,
|
||||||
backgroundColor: pt.bg0,
|
backgroundColor: pt.bg0,
|
||||||
child: const SpacesRail(),
|
child: const SafeArea(child: SpacesRail()),
|
||||||
),
|
),
|
||||||
body: Stack(
|
// SafeArea: Abstand zu Statusleiste/Kamera-Aussparung oben und zur
|
||||||
|
// Gesten-/Navigationsleiste unten (edge-to-edge, siehe main.dart).
|
||||||
|
body: SafeArea(child: Stack(
|
||||||
children: [
|
children: [
|
||||||
body,
|
body,
|
||||||
if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice) ...[
|
if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice) ...[
|
||||||
@@ -400,7 +415,8 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
child: _NotificationBlockedBanner(),
|
child: _NotificationBlockedBanner(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -33,8 +33,16 @@ void main() async {
|
|||||||
|
|
||||||
await vod.init();
|
await vod.init();
|
||||||
|
|
||||||
// Hide status bar and navigation bar for immersive feel
|
// Status- und Navigationsleiste bleiben sichtbar (edge-to-edge): die App
|
||||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
// zeichnet darunter, hält per SafeArea (Handy-Layout in app_shell.dart)
|
||||||
|
// aber Abstand. Vorher immersiveSticky – da schob sich die Statusleiste
|
||||||
|
// beim Herunterwischen über Kopfzeile und Text (Bernds Feedback 2026-10-07).
|
||||||
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
|
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||||
|
statusBarColor: Colors.transparent,
|
||||||
|
systemNavigationBarColor: Colors.transparent,
|
||||||
|
systemNavigationBarContrastEnforced: false,
|
||||||
|
));
|
||||||
|
|
||||||
runApp(const ProviderScope(child: PyramidBootstrap()));
|
runApp(const ProviderScope(child: PyramidBootstrap()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,6 +350,11 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_driver:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -517,6 +522,11 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
version: "4.0.0"
|
||||||
|
fuchsia_remote_debug_protocol:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
glob:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -581,6 +591,11 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.8.0"
|
version: "4.8.0"
|
||||||
|
integration_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1005,6 +1020,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.6.0"
|
version: "3.6.0"
|
||||||
|
process:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: process
|
||||||
|
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.6"
|
||||||
protobuf:
|
protobuf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1274,6 +1297,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
sync_http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sync_http
|
||||||
|
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.1"
|
||||||
synchronized:
|
synchronized:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1515,6 +1546,14 @@ packages:
|
|||||||
url: "https://github.com/google/webcrypto.dart.git"
|
url: "https://github.com/google/webcrypto.dart.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.6.0"
|
version: "0.6.0"
|
||||||
|
webdriver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webdriver
|
||||||
|
sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.0"
|
||||||
webrtc_interface:
|
webrtc_interface:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ dependencies:
|
|||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
# UI-Durchläufe mit der echten App (integration_test/), Teil des Flutter-SDKs.
|
||||||
|
integration_test:
|
||||||
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
flutter_launcher_icons: ^0.13.1
|
flutter_launcher_icons: ^0.13.1
|
||||||
# Nur für Tests: offizieller Weg, path_provider ohne echte Plattform zu
|
# Nur für Tests: offizieller Weg, path_provider ohne echte Plattform zu
|
||||||
|
|||||||
+24
-1
@@ -5,6 +5,8 @@
|
|||||||
# pwsh scripts/test.ps1 -Live + Live-Test gegen steggi-matrix.work
|
# pwsh scripts/test.ps1 -Live + Live-Test gegen steggi-matrix.work
|
||||||
# (Test-Accounts pyramidtest1/2, NICHT Bernd/Uta)
|
# (Test-Accounts pyramidtest1/2, NICHT Bernd/Uta)
|
||||||
# pwsh scripts/test.ps1 -Bench + Benchmarks (Datenbank/Verschlüsselung, Migration)
|
# pwsh scripts/test.ps1 -Bench + Benchmarks (Datenbank/Verschlüsselung, Migration)
|
||||||
|
# pwsh scripts/test.ps1 -Ui + UI-Durchlauf im Handy-Format mit Screenshots
|
||||||
|
# (echte App, Testprofil pyramidtest1 → build/ui_screens/)
|
||||||
# pwsh scripts/test.ps1 -All alles zusammen
|
# pwsh scripts/test.ps1 -All alles zusammen
|
||||||
# pwsh scripts/test.ps1 -Bench -UpdateBaseline
|
# pwsh scripts/test.ps1 -Bench -UpdateBaseline
|
||||||
# Benchmark-Werte als neue Baseline speichern
|
# Benchmark-Werte als neue Baseline speichern
|
||||||
@@ -16,6 +18,7 @@
|
|||||||
param(
|
param(
|
||||||
[switch]$Live,
|
[switch]$Live,
|
||||||
[switch]$Bench,
|
[switch]$Bench,
|
||||||
|
[switch]$Ui,
|
||||||
[switch]$All,
|
[switch]$All,
|
||||||
[switch]$UpdateBaseline,
|
[switch]$UpdateBaseline,
|
||||||
[switch]$SkipAnalyze
|
[switch]$SkipAnalyze
|
||||||
@@ -27,7 +30,7 @@ $ErrorActionPreference = "Stop"
|
|||||||
# Immer aus dem Projektordner arbeiten, egal von wo das Skript aufgerufen wird.
|
# Immer aus dem Projektordner arbeiten, egal von wo das Skript aufgerufen wird.
|
||||||
Set-Location (Split-Path $PSScriptRoot -Parent)
|
Set-Location (Split-Path $PSScriptRoot -Parent)
|
||||||
|
|
||||||
if ($All) { $Live = $true; $Bench = $true }
|
if ($All) { $Live = $true; $Bench = $true; $Ui = $true }
|
||||||
|
|
||||||
$results = [System.Collections.Generic.List[object]]::new()
|
$results = [System.Collections.Generic.List[object]]::new()
|
||||||
|
|
||||||
@@ -99,6 +102,26 @@ if ($Bench) {
|
|||||||
Write-Host "Benchmark-Berichte: build/benchmarks/*.json (Baseline: benchmark/baseline/)"
|
Write-Host "Benchmark-Berichte: build/benchmarks/*.json (Baseline: benchmark/baseline/)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($Ui) {
|
||||||
|
$uiProfile = Join-Path $env:USERPROFILE ".pyramid-autopilot\profile1"
|
||||||
|
if (-not (Test-Path (Join-Path $uiProfile "pyramid.sqlite"))) {
|
||||||
|
Add-Skipped "UI-Durchlauf (Handy-Format)" "Testprofil fehlt: $uiProfile"
|
||||||
|
} elseif (Get-Process pyramid -ErrorAction SilentlyContinue) {
|
||||||
|
# Nie zwei Matrix-Clients auf derselben DB – und Bernds Fenster nicht stören.
|
||||||
|
Add-Skipped "UI-Durchlauf (Handy-Format)" "Pyramid läuft gerade – erst beenden"
|
||||||
|
} else {
|
||||||
|
$env:PYRAMID_PROFILE_DIR = $uiProfile
|
||||||
|
try {
|
||||||
|
Invoke-Step "UI-Durchlauf (Handy-Format, Testprofil)" {
|
||||||
|
flutter test integration_test/mobile_ui_screens_test.dart -d windows
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Remove-Item Env:PYRAMID_PROFILE_DIR -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
Write-Host "Screenshots: build/ui_screens/ (Testinhalte ggf. mit test/live_ui_fixture_test.dart anlegen)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "━━ Zusammenfassung" -ForegroundColor Cyan
|
Write-Host "━━ Zusammenfassung" -ForegroundColor Cyan
|
||||||
$results | Format-Table -AutoSize | Out-String | Write-Host
|
$results | Format-Table -AutoSize | Out-String | Write-Host
|
||||||
|
|||||||
@@ -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