feat(call): aufgeblasene Formen statt runder Bubbles (Bernds Referenzbild)

- Neues Layout lib/features/call_ui/inflated_layout.dart: gewichtetes
  Voronoi-/Potenzdiagramm im Anrufbereich, Zellen fuellen das Fenster bis an
  die Raender, an Nachbarn plattgedrueckt, weich gerundete Ecken;
  Lloyd-Relaxation + sanfter Zug ins Raster (aufgeraeumt), Flaechen per
  Gewicht nachgefuehrt (Sprecher 1,7-fach), Neue pumpen sich auf, Gehende
  schrumpfen weg, je mehr Personen desto kleiner
- Darstellung nur als Kontur mit weichem Innenleuchten, Profilbild mit
  Kontur + Name in der Mitte, Sprecher gruen pulsierend, Kamera-Bild
  fuellt die Form
- Tests: test/inflated_layout_test.dart (7), Vorschau-/Animationsbilder
  integration_test/call_bubbles_demo_test.dart; alte Kugel-Physik entfernt
- Version 0.1.43

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-10 14:54:45 +02:00
co-authored by Claude Opus 5.5
parent d4473c7d64
commit ab84701e73
8 changed files with 809 additions and 468 deletions
@@ -0,0 +1,111 @@
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/core/theme.dart';
import 'package:pyramid/features/call_ui/voice_channel.dart' show CallBubblesDemo;
/// Vorschaubilder der Anrufansicht mit erfundenen Teilnehmern (ohne Login):
/// Standbilder für 1–7 Personen (Handy + PC) und eine Bildfolge der
/// Animation (Sprecherwechsel, jemand kommt dazu, jemand geht) für ein GIF.
/// Bilder: build/call_demo/.
///
/// flutter test integration_test/call_bubbles_demo_test.dart -d windows
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Anruf-Formen: Vorschaubilder', (tester) async {
final count = ValueNotifier(5);
final speaking = ValueNotifier(2);
void phone() => tester.view
..physicalSize = const Size(1080, 2340)
..devicePixelRatio = 2.625;
void desktop() => tester.view
..physicalSize = const Size(1600, 1000)
..devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
Future<void> smooth(Duration d) async {
final end = DateTime.now().add(d);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 16));
}
}
phone();
await tester.pumpWidget(PyramidTheme(
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: buildPyramidThemeData(ThemeVariant.soft),
home: ValueListenableBuilder<int>(
valueListenable: count,
builder: (_, n, _) => ValueListenableBuilder<int>(
valueListenable: speaking,
builder: (_, s, _) => CallBubblesDemo(count: n, speaking: s),
),
),
),
));
// ── Standbilder ──
for (final n in [1, 2, 3, 5, 7]) {
count.value = n;
speaking.value = n >= 3 ? 2 : -1;
phone();
await smooth(const Duration(milliseconds: 2500));
await _shot(tester, 'p_${n}_personen');
desktop();
await smooth(const Duration(milliseconds: 2500));
await _shot(tester, 'd_${n}_personen');
}
// ── Animation (Handy) ──
phone();
count.value = 4;
speaking.value = -1;
await smooth(const Duration(seconds: 3));
var frame = 0;
Future<void> record(Duration d) async {
final end = DateTime.now().add(d);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 16));
await _shot(tester, 'anim/f${(frame++).toString().padLeft(4, '0')}',
scale: 0.4, log: false);
}
}
await record(const Duration(milliseconds: 600));
speaking.value = 1; // Anna spricht
await record(const Duration(milliseconds: 1600));
speaking.value = 3; // Clara spricht
await record(const Duration(milliseconds: 1600));
speaking.value = -1;
count.value = 5; // David kommt dazu
await record(const Duration(milliseconds: 1800));
count.value = 4; // David geht wieder
await record(const Duration(milliseconds: 1800));
// ignore: avoid_print
print('Animation: $frame Bilder');
}, timeout: const Timeout(Duration(minutes: 6)));
}
Future<void> _shot(WidgetTester tester, String name,
{double scale = 1, bool log = true}) async {
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, pixelRatio: scale),
);
final bytes = await tester.binding.runAsync(
() => image!.toByteData(format: ui.ImageByteFormat.png),
);
final file = File('build/call_demo/$name.png');
file.parent.createSync(recursive: true);
file.writeAsBytesSync(bytes!.buffer.asUint8List());
// ignore: avoid_print
if (log) print('Bild $name');
}