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
+344
View File
@@ -0,0 +1,344 @@
import 'dart:math' as math;
import 'dart:ui';
/// Layout der Anrufansicht als „aufgeblasene“ Formen (Bernd 2026-10-10,
/// Referenz: aufgepumpte Buchstaben in einer Kiste): Jede Person bekommt eine
/// Zelle, alle Zellen zusammen füllen den ganzen Anrufbereich bis an die
/// Ränder. Wo zwei Zellen aneinanderstoßen, sind sie plattgedrückt, außen
/// liegen sie am Fensterrand an, die Ecken sind weich gerundet ([outline]).
///
/// Technik: gewichtetes Voronoi-Diagramm (Potenzdiagramm) im Rechteck. Jede
/// Zelle hat einen Anteil an der Fläche ([InflatedCell.share], wer spricht
/// bekommt mehr). In jedem Schritt wandert der Mittelpunkt einer Zelle zu
/// ihrem Schwerpunkt (gleichmäßige, ruhige Formen) und ihr Gewicht wird so
/// nachgeführt, dass die Fläche dem Anteil entspricht. Weil das schrittweise
/// geschieht, sieht man es als federndes Auf- und Abpumpen. Je mehr Personen,
/// desto kleiner die Zellen.
class InflatedCell {
InflatedCell(this.id, this.site, this.weight, {this.share = 1});
final String id;
/// Mittelpunkt (Erzeuger) der Zelle.
Offset site;
/// Platz im Raster, zu dem die Zelle sanft gezogen wird (aufgeräumte
/// Anordnung); null = keiner (z. B. beim Gehen).
Offset? slot;
/// Gewicht im Potenzdiagramm (Einheit: Fläche, wie Radius²).
double weight;
/// Aktueller (animierter) und gewünschter Flächenanteil.
double share;
double targetShare = 1;
bool get leaving => targetShare == 0;
/// Ergebnis der letzten Berechnung.
List<Offset> polygon = const [];
double area = 0;
Offset centroid = Offset.zero;
}
class InflatedLayout {
InflatedLayout({this.gap = 10, this.margin = 12});
/// Wie viel mehr Fläche bekommt, wer gerade spricht.
static const speakingShare = 1.7;
/// Abstand zwischen zwei Zellen bzw. zum Fensterrand.
final double gap;
final double margin;
final Map<String, InflatedCell> cells = {};
Size _size = Size.zero;
Size get size => _size;
Rect get bounds => Rect.fromLTWH(
margin,
margin,
math.max(0, _size.width - 2 * margin),
math.max(0, _size.height - 2 * margin),
);
/// Gleicht die Zellen mit den Teilnehmern ab ([speaking] = wer spricht).
void sync(List<String> ids, Set<String> speaking, Size size) {
final old = _size;
_size = size;
if (size != old && old.width > 0 && old.height > 0 && cells.isNotEmpty) {
final flipped = (old.width >= old.height) != (size.width >= size.height);
if (flipped) {
// Hochkant ↔ quer: neu anordnen, sonst bleiben z. B. zwei Zellen
// übereinander, obwohl nebeneinander viel besser passt.
_placeOnGrid(cells.values.toList());
} else {
final sx = size.width / old.width, sy = size.height / old.height;
for (final c in cells.values) {
c.site = Offset(c.site.dx * sx, c.site.dy * sy);
c.weight *= sx * sy;
}
}
}
final present = ids.toSet();
for (final c in cells.values) {
if (!present.contains(c.id)) c.targetShare = 0;
}
final fresh = ids.where((id) => !cells.containsKey(id)).toList();
if (cells.isEmpty) {
// Erster Aufbau: gleich voll da, im Raster angeordnet.
final created = [for (final id in fresh) InflatedCell(id, Offset.zero, 0)];
_placeOnGrid(created);
for (final c in created) {
cells[c.id] = c;
}
} else {
for (final id in fresh) {
// Neu dazu: entsteht in der größten Zelle und pumpt sich dort auf.
final host = cells.values.reduce((a, b) => a.area >= b.area ? a : b);
final h = id.hashCode;
final dir = Offset(math.cos(h % 360.0), math.sin(h % 360.0));
final spread = math.sqrt(math.max(host.area, 1)) * 0.2;
final site = _inside(host.centroid + dir * spread);
// Gewicht knapp über der Grenze, ab der die Zelle leer wäre
// (w ≤ w_host − d²): sie beginnt winzig und pumpt sich dann auf.
final d2 = (site - host.site).distanceSquared;
cells[id] = InflatedCell(
id,
site,
host.weight - d2 * 0.9,
share: 0.15,
);
}
}
for (final id in ids) {
cells[id]!.targetShare = speaking.contains(id) ? speakingShare : 1.0;
}
final slots = _gridPoints(ids.length);
for (var i = 0; i < ids.length; i++) {
cells[ids[i]]!.slot = i < slots.length ? slots[i] : null;
}
for (final c in cells.values) {
if (c.leaving) c.slot = null;
}
_compute();
}
void _placeOnGrid(List<InflatedCell> list) {
final points = _gridPoints(list.length);
for (var i = 0; i < list.length && i < points.length; i++) {
list[i]
..site = points[i]
..weight = 0;
}
}
/// Mittelpunkte eines Rasters für [n] Zellen: möglichst quadratische
/// Zellen, wenig Lücken; die letzte Reihe wird mittig verteilt.
List<Offset> _gridPoints(int n) {
final b = bounds;
if (n == 0 || b.isEmpty) return const [];
var cols = 1;
var best = double.infinity;
for (var c = 1; c <= n; c++) {
final r = (n / c).ceil();
final aspect = (b.width / c) / (b.height / r);
final score = math.log(aspect).abs() + 0.35 * (c * r - n);
if (score < best) {
best = score;
cols = c;
}
}
final rows = (n / cols).ceil();
return [
for (var i = 0; i < n; i++)
() {
final row = i ~/ cols;
final inRow = row == rows - 1 ? n - row * cols : cols;
final col = i % cols;
// Letzte (unvollständige) Reihe: gleichmäßig über die Breite.
final x = row == rows - 1 && inRow < cols
? b.left + b.width * (col + 0.5) / inRow
: b.left + b.width * (col + 0.5) / cols;
return Offset(x, b.top + b.height * (row + 0.5) / rows);
}(),
];
}
Offset _inside(Offset p) {
final b = bounds.deflate(1);
return Offset(p.dx.clamp(b.left, b.right), p.dy.clamp(b.top, b.bottom));
}
/// Ein Zeitschritt (Sekunden). `true`, solange sich noch etwas bewegt.
bool step(double dt) {
if (_size.isEmpty || cells.isEmpty) return false;
dt = dt.clamp(0.0, 1 / 20);
var moving = false;
final shareRate = 1 - math.exp(-dt * 6);
for (final c in cells.values) {
final d = c.targetShare - c.share;
if (d.abs() > 0.01) moving = true;
c.share += d * shareRate;
}
cells.removeWhere((_, c) => c.leaving && c.share < 0.03);
if (cells.isEmpty) return false;
_compute();
final total = cells.values.fold<double>(0, (s, c) => s + c.area);
final shareSum = cells.values.fold<double>(0, (s, c) => s + c.share);
final f = math.min(1.0, dt * 10);
final move = 1 - math.exp(-dt * 8);
for (final c in cells.values) {
// Fläche zum Anteil passend machen: Gewicht direkt um den Fehler
// verschieben (Gewicht und Fläche haben dieselbe Einheit; ein Plus an
// Gewicht schiebt alle Grenzen der Zelle nach außen). Gedämpft, damit
// es federnd statt ruckartig wirkt.
final target = total * c.share / shareSum;
final error = target - c.area;
if (error.abs() > target * 0.02) moving = true;
c.weight += 0.4 * f * error;
// … und den Mittelpunkt zu Schwerpunkt bzw. Rasterplatz ziehen.
if (c.area > 1) {
final goal = c.slot == null
? c.centroid
: Offset.lerp(c.centroid, c.slot!, 0.5)!;
final d = goal - c.site;
// Ruhe, sobald sich nichts mehr sichtbar bewegt (< ~3 px/s).
if (d.distance * move > 0.05) moving = true;
c.site = _inside(c.site + d * move);
}
}
// Nur Unterschiede zählen – Mittelwert bei 0 halten.
final mean =
cells.values.fold<double>(0, (s, c) => s + c.weight) / cells.length;
for (final c in cells.values) {
c.weight -= mean;
}
_clampWeights();
_compute();
return moving;
}
/// Ein Erzeuger muss in seiner eigenen Zelle liegen: w_j − w_i ≤ d².
void _clampWeights() {
final list = cells.values.toList();
for (var i = 0; i < list.length; i++) {
for (var j = 0; j < list.length; j++) {
if (i == j) continue;
final d2 = (list[i].site - list[j].site).distanceSquared;
if (list[j].weight - list[i].weight > d2) {
list[j].weight = list[i].weight + d2;
}
}
}
}
void _compute() {
final b = bounds;
final list = cells.values.toList();
for (final c in list) {
var poly = <Offset>[b.topLeft, b.topRight, b.bottomRight, b.bottomLeft];
for (final o in list) {
if (identical(o, c) || poly.isEmpty) continue;
final n = (o.site - c.site) * 2;
final len = n.distance;
if (len < 1e-6) continue;
// Grenze des Potenzdiagramms, um die halbe Lücke zu c hin versetzt.
final limit = o.site.distanceSquared -
c.site.distanceSquared +
c.weight -
o.weight -
gap / 2 * len;
poly = _clip(poly, n, limit);
}
c.polygon = poly;
_measure(c);
}
}
/// Polygon auf die Halbebene n·x ≤ limit beschneiden.
static List<Offset> _clip(List<Offset> poly, Offset n, double limit) {
final out = <Offset>[];
for (var i = 0; i < poly.length; i++) {
final a = poly[i], b = poly[(i + 1) % poly.length];
final da = n.dx * a.dx + n.dy * a.dy - limit;
final db = n.dx * b.dx + n.dy * b.dy - limit;
if (da <= 0) out.add(a);
if ((da <= 0) != (db <= 0)) {
out.add(a + (b - a) * (da / (da - db)));
}
}
return out;
}
static void _measure(InflatedCell c) {
final p = c.polygon;
if (p.length < 3) {
c.area = 0;
return;
}
var a2 = 0.0, cx = 0.0, cy = 0.0;
for (var i = 0; i < p.length; i++) {
final q = p[i], r = p[(i + 1) % p.length];
final cross = q.dx * r.dy - r.dx * q.dy;
a2 += cross;
cx += (q.dx + r.dx) * cross;
cy += (q.dy + r.dy) * cross;
}
if (a2.abs() < 1e-6) {
c.area = 0;
return;
}
c.area = a2.abs() / 2;
c.centroid = Offset(cx / (3 * a2), cy / (3 * a2));
}
/// Umriss von [id]: die Zelle mit weich gerundeten Ecken („aufgeblasen“).
Path outline(String id) {
final c = cells[id];
final path = Path();
if (c == null) return path;
final p = <Offset>[];
for (final v in c.polygon) {
if (p.isEmpty || (v - p.last).distance > 0.5) p.add(v);
}
if (p.length > 2 && (p.first - p.last).distance <= 0.5) p.removeLast();
if (p.length < 3) return path;
final rcMax = math.min(72.0, math.sqrt(c.area) * 0.34);
for (var i = 0; i < p.length; i++) {
final v = p[i];
final prev = p[(i - 1 + p.length) % p.length];
final next = p[(i + 1) % p.length];
final lp = (prev - v).distance, ln = (next - v).distance;
final r = math.min(rcMax, math.min(lp, ln) * 0.5);
final a = v + (prev - v) / lp * r;
final b = v + (next - v) / ln * r;
if (i == 0) {
path.moveTo(a.dx, a.dy);
} else {
path.lineTo(a.dx, a.dy);
}
// Kubische Ecke (etwas voller als ein Viertelkreis – wirkt prall).
final c1 = a + (v - a) * 0.62, c2 = b + (v - b) * 0.62;
path.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, b.dx, b.dy);
}
path.close();
return path;
}
/// Rechteck um die Zelle (für Inhalte wie Video).
Rect cellBounds(String id) {
final p = cells[id]?.polygon ?? const [];
if (p.isEmpty) return Rect.zero;
var l = p.first.dx, t = p.first.dy, r = l, b = t;
for (final v in p) {
l = math.min(l, v.dx);
t = math.min(t, v.dy);
r = math.max(r, v.dx);
b = math.max(b, v.dy);
}
return Rect.fromLTRB(l, t, r, b);
}
}