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
-94
View File
@@ -1,94 +0,0 @@
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:pyramid/features/call_ui/bubble_physics.dart';
/// Lässt die Simulation [seconds] lang laufen (60 Schritte pro Sekunde).
bool _run(BubbleSim sim, double seconds) {
var moving = true;
for (var i = 0; i < (seconds * 60).round(); i++) {
moving = sim.step(1 / 60);
}
return moving;
}
void main() {
const phone = Size(390, 620);
const desktop = Size(1200, 700);
test('zwei Bubbles drücken sich leicht aneinander und kommen zur Ruhe', () {
final sim = BubbleSim()..sync(['a', 'b'], {}, desktop);
final moving = _run(sim, 6);
expect(moving, isFalse, reason: 'Simulation kommt nicht zur Ruhe');
final a = sim.bodies['a']!, b = sim.bodies['b']!;
final dist = (a.pos - b.pos).distance;
// Sie berühren sich (überlappen leicht) statt frei zu schweben …
expect(dist, lessThan(a.r + b.r));
// … liegen aber nicht ineinander.
expect(dist, greaterThan((a.r + b.r) * 0.8));
// Querformat: nebeneinander statt übereinander
expect((a.pos.dy - b.pos.dy).abs(), lessThan((a.pos.dx - b.pos.dx).abs()));
});
test('wer spricht, wird größer', () {
final sim = BubbleSim()..sync(['a', 'b', 'c'], {}, phone);
_run(sim, 4);
final before = sim.bodies['b']!.r;
sim.sync(['a', 'b', 'c'], {'b'}, phone);
_run(sim, 4);
final b = sim.bodies['b']!.r;
expect(b, greaterThan(before * 1.1));
expect(b, greaterThan(sim.bodies['a']!.r * 1.1));
});
test('viele Teilnehmer bleiben im Bild (Handy hochkant)', () {
final ids = [for (var i = 0; i < 8; i++) 'p$i'];
final sim = BubbleSim()..sync(ids, {}, phone);
_run(sim, 8);
for (final b in sim.bodies.values) {
expect(b.pos.dx - b.r, greaterThan(-2), reason: '${b.id} links raus');
expect(b.pos.dx + b.r, lessThan(phone.width + 2), reason: '${b.id} rechts raus');
expect(b.pos.dy - b.r, greaterThan(-2), reason: '${b.id} oben raus');
expect(b.pos.dy + b.r, lessThan(phone.height + 2), reason: '${b.id} unten raus');
}
});
test('Gehende schrumpfen weg und verschwinden', () {
final sim = BubbleSim()..sync(['a', 'b'], {}, phone);
_run(sim, 3);
sim.sync(['a'], {}, phone);
_run(sim, 3);
expect(sim.bodies.keys, ['a']);
});
test('Berührungsfläche ist abgeflacht, freier Rand bleibt rund', () {
final sim = BubbleSim()..sync(['a', 'b'], {}, desktop);
_run(sim, 6);
final a = sim.bodies['a']!, b = sim.bodies['b']!;
final bounds = sim.clipPath('a').getBounds();
final toB = (b.pos - a.pos);
// In Richtung des Nachbarn endet die Bubble vor ihrem Kreisrand …
if (toB.dx > 0) {
expect(bounds.right, lessThan(a.pos.dx + a.r - 1));
// … auf der freien Seite reicht sie bis zum Kreisrand.
expect(bounds.left, closeTo(a.pos.dx - a.r, 1));
} else {
expect(bounds.left, greaterThan(a.pos.dx - a.r + 1));
expect(bounds.right, closeTo(a.pos.dx + a.r, 1));
}
// Mittelpunkt liegt im Umriss, der Nachbar-Mittelpunkt nicht.
expect(sim.clipPath('a').contains(a.pos), isTrue);
expect(sim.clipPath('a').contains(b.pos), isFalse);
});
test('einzelne Bubble: rund, mittig', () {
final sim = BubbleSim()..sync(['solo'], {}, phone);
_run(sim, 4);
final s = sim.bodies['solo']!;
expect(s.pos.dx, closeTo(phone.width / 2, 2));
expect(s.pos.dy, closeTo(phone.height / 2, 2));
final bounds = sim.clipPath('solo').getBounds();
expect(bounds.width, closeTo(s.r * 2, 1));
});
}
+107
View File
@@ -0,0 +1,107 @@
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:pyramid/features/call_ui/inflated_layout.dart';
/// [seconds] lang simulieren (60 Schritte pro Sekunde).
bool _run(InflatedLayout l, double seconds) {
var moving = true;
for (var i = 0; i < (seconds * 60).round(); i++) {
moving = l.step(1 / 60);
}
return moving;
}
List<String> _ids(int n) => [for (var i = 0; i < n; i++) 'p$i'];
void main() {
const phone = Size(390, 620);
const desktop = Size(1200, 700);
test('eine Person füllt den ganzen Bereich bis an die Ränder', () {
final l = InflatedLayout()..sync(['solo'], {}, phone);
_run(l, 1);
final r = l.cellBounds('solo');
expect(r.left, closeTo(l.margin, 0.5));
expect(r.top, closeTo(l.margin, 0.5));
expect(r.right, closeTo(phone.width - l.margin, 0.5));
expect(r.bottom, closeTo(phone.height - l.margin, 0.5));
// Gerundete Ecken: der Umriss enthält die Ecke des Rechtecks nicht mehr.
final path = l.outline('solo');
expect(path.contains(r.topLeft + const Offset(2, 2)), isFalse);
expect(path.contains(r.center), isTrue);
});
test('vier Personen: gleich große Zellen, zusammen fast die ganze Fläche', () {
final l = InflatedLayout()..sync(_ids(4), {}, desktop);
final moving = _run(l, 6);
expect(moving, isFalse, reason: 'kommt nicht zur Ruhe');
final areas = l.cells.values.map((c) => c.area).toList();
final avg = areas.reduce((a, b) => a + b) / areas.length;
for (final a in areas) {
expect(a, closeTo(avg, avg * 0.08));
}
final total = areas.reduce((a, b) => a + b);
expect(total, greaterThan(l.bounds.width * l.bounds.height * 0.9));
});
test('wer spricht, bekommt deutlich mehr Fläche', () {
final l = InflatedLayout()..sync(_ids(3), {}, phone);
_run(l, 4);
l.sync(_ids(3), {'p1'}, phone);
_run(l, 5);
final speaker = l.cells['p1']!.area;
final other = l.cells['p0']!.area;
expect(speaker / other,
closeTo(InflatedLayout.speakingShare, InflatedLayout.speakingShare * 0.15));
});
test('je mehr Personen, desto kleiner die Zellen', () {
double avgArea(int n) {
final l = InflatedLayout()..sync(_ids(n), {}, phone);
_run(l, 5);
return l.cells.values.fold<double>(0, (s, c) => s + c.area) / n;
}
final a2 = avgArea(2), a5 = avgArea(5), a9 = avgArea(9);
expect(a5, lessThan(a2));
expect(a9, lessThan(a5));
});
test('Neue pumpen sich auf, Gehende verschwinden', () {
final l = InflatedLayout()..sync(_ids(2), {}, phone);
_run(l, 3);
l.sync(_ids(3), {}, phone);
expect(l.cells['p2']!.area, lessThan(l.cells['p0']!.area));
_run(l, 5);
expect(l.cells['p2']!.area, closeTo(l.cells['p0']!.area, l.cells['p0']!.area * 0.1));
l.sync(['p0', 'p2'], {}, phone);
_run(l, 3);
expect(l.cells.keys.toSet(), {'p0', 'p2'});
});
test('Zellen überlappen nicht und jeder Mittelpunkt liegt in seiner Zelle', () {
final l = InflatedLayout()..sync(_ids(7), {'p3'}, desktop);
_run(l, 6);
final paths = {for (final id in l.cells.keys) id: l.outline(id)};
for (final c in l.cells.values) {
expect(paths[c.id]!.contains(c.centroid), isTrue, reason: '${c.id} Schwerpunkt');
for (final other in l.cells.values) {
if (other.id == c.id) continue;
expect(paths[other.id]!.contains(c.centroid), isFalse,
reason: '${c.id} liegt in ${other.id}');
}
}
});
test('Drehen (hochkant → quer): zwei Personen nebeneinander', () {
final l = InflatedLayout()..sync(_ids(2), {}, phone);
_run(l, 3);
final a = l.cells['p0']!.centroid, b = l.cells['p1']!.centroid;
expect((a.dy - b.dy).abs(), greaterThan((a.dx - b.dx).abs()), reason: 'hochkant: übereinander');
l.sync(_ids(2), {}, desktop);
_run(l, 3);
final c = l.cells['p0']!.centroid, d = l.cells['p1']!.centroid;
expect((c.dx - d.dx).abs(), greaterThan((c.dy - d.dy).abs()), reason: 'quer: nebeneinander');
});
}