From ab84701e737e08e7d164edabe3d408114bc163fa Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Sat, 10 Oct 2026 14:54:45 +0200 Subject: [PATCH] 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 --- integration_test/call_bubbles_demo_test.dart | 111 +++++ lib/features/call_ui/bubble_physics.dart | 201 --------- lib/features/call_ui/inflated_layout.dart | 344 +++++++++++++++ lib/features/call_ui/view/bubble_stage.dart | 416 +++++++++++-------- lib/features/call_ui/voice_channel.dart | 2 +- pubspec.yaml | 2 +- test/bubble_physics_test.dart | 94 ----- test/inflated_layout_test.dart | 107 +++++ 8 files changed, 809 insertions(+), 468 deletions(-) create mode 100644 integration_test/call_bubbles_demo_test.dart delete mode 100644 lib/features/call_ui/bubble_physics.dart create mode 100644 lib/features/call_ui/inflated_layout.dart delete mode 100644 test/bubble_physics_test.dart create mode 100644 test/inflated_layout_test.dart diff --git a/integration_test/call_bubbles_demo_test.dart b/integration_test/call_bubbles_demo_test.dart new file mode 100644 index 0000000..9b73778 --- /dev/null +++ b/integration_test/call_bubbles_demo_test.dart @@ -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 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( + valueListenable: count, + builder: (_, n, _) => ValueListenableBuilder( + 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 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 _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'); +} diff --git a/lib/features/call_ui/bubble_physics.dart b/lib/features/call_ui/bubble_physics.dart deleted file mode 100644 index dbdf3fb..0000000 --- a/lib/features/call_ui/bubble_physics.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'dart:math' as math; -import 'dart:ui'; - -/// Physik der Bubble-Anrufansicht (Bernd 2026-10-09: „animierte dynamische -/// Bubbles, die sich aneinander anpassen und aussehen, als würden sie -/// aneinander gedrückt“). -/// -/// Jede Person ist ein Kreis. Eine Feder zieht alle Kreise zur Mitte, Kreise -/// stoßen sich ab, sobald sie sich mehr als [overlap] überlappen, und die -/// Ränder halten sie im Bild. Im Gleichgewicht drücken sie also leicht -/// gegeneinander; [clipPath] schneidet jeden Kreis an der Berührungslinie -/// (Potenzgerade) gerade ab – wie Seifenblasen in Schaum. -/// -/// Wer spricht, wird größer und schiebt die anderen sanft beiseite; neue -/// Teilnehmer wachsen aus dem Nichts heraus, Gehende schrumpfen weg. -class BubbleBody { - final String id; - Offset pos; - Offset vel = Offset.zero; - double r; - double rVel = 0; - double targetR = 0; - bool leaving = false; - - BubbleBody(this.id, this.pos, {this.r = 0}); -} - -class BubbleSim { - /// Anteil der Kreisdurchmesser, um den sich Nachbarn überlappen dürfen – - /// daraus entstehen die flachen Berührungsflächen. - static const overlap = 0.10; - - /// Wie viel größer ein sprechender Kreis wird. - static const speakingScale = 1.22; - - final Map bodies = {}; - Size _size = Size.zero; - Size get size => _size; - Offset get _center => Offset(_size.width / 2, _size.height / 2); - - /// Gleicht die Kreise mit den aktuellen Teilnehmern ab: neue kommen dazu, - /// fehlende schrumpfen weg, die Zielgrößen werden neu verteilt. - /// [speaking] = IDs, die gerade sprechen. - void sync(List ids, Set speaking, Size size) { - _size = size; - final present = ids.toSet(); - for (final b in bodies.values) { - b.leaving = !present.contains(b.id); - } - for (final id in ids) { - if (bodies.containsKey(id)) continue; - // Neu: nahe der Mitte (leicht versetzt, damit es die anderen - // auseinanderschiebt statt exakt auf ihnen zu liegen). - final h = id.hashCode; - final angle = (h % 360) * math.pi / 180; - final spread = math.min(size.width, size.height) * 0.08; - bodies[id] = BubbleBody( - id, - _center + Offset(math.cos(angle), math.sin(angle)) * spread, - ); - } - _assignTargets(ids, speaking); - } - - void _assignTargets(List ids, Set speaking) { - final w = _size.width, h = _size.height; - if (w <= 0 || h <= 0) return; - final shortSide = math.min(w, h); - double weight(String id) => speaking.contains(id) ? speakingScale : 1.0; - final active = ids.where((id) => bodies.containsKey(id)).toList(); - double base; - if (active.length <= 1) { - base = shortSide * 0.27; - } else { - // Kreise sollen zusammen gut ein Drittel der Fläche bedecken. - final sumW2 = active.fold(0, (s, id) => s + weight(id) * weight(id)); - base = math.sqrt(0.36 * w * h / (math.pi * sumW2)); - } - final maxR = shortSide * (active.length <= 2 ? 0.27 : 0.36); - for (final b in bodies.values) { - if (b.leaving) { - b.targetR = 0; - } else { - b.targetR = (base * weight(b.id)).clamp(28.0, maxR); - } - } - } - - /// Ein Zeitschritt in Sekunden. Liefert `true`, solange sich noch etwas - /// bewegt (sonst kann die Animation pausieren). - bool step(double dt) { - if (_size.isEmpty) return false; - dt = dt.clamp(0.0, 1 / 30); - // Zwei Unterschritte: stabil auch bei großen Kräften. - var moving = false; - for (var i = 0; i < 2; i++) { - moving = _substep(dt / 2) || moving; - } - bodies.removeWhere((_, b) => b.leaving && b.r < 1); - return moving; - } - - bool _substep(double dt) { - const centerK = 18.0; // Zug zur Mitte - const pushK = 260.0; // Abstoßung bei zu starker Überlappung - const wallK = 160.0; - const damping = 9.0; - const radiusK = 110.0; - const radiusDamping = 13.0; - const margin = 10.0; - - final list = bodies.values.toList(); - final forces = List.filled(list.length, Offset.zero); - final c = _center; - for (var i = 0; i < list.length; i++) { - final a = list[i]; - // Die Mitte zieht, aber gestaucht in der langen Richtung, damit sich - // die Kreise im Hochformat übereinander, im Querformat nebeneinander - // anordnen. - final d = a.pos - c; - final ax = _size.width >= _size.height ? 0.55 : 1.0; - final ay = _size.height > _size.width ? 0.55 : 1.0; - forces[i] += Offset(-d.dx * centerK * ax, -d.dy * centerK * ay); - - // Ränder - final left = a.pos.dx - a.r - margin; - final right = _size.width - margin - (a.pos.dx + a.r); - final top = a.pos.dy - a.r - margin; - final bottom = _size.height - margin - (a.pos.dy + a.r); - if (left < 0) forces[i] += Offset(-left * wallK, 0); - if (right < 0) forces[i] += Offset(right * wallK, 0); - if (top < 0) forces[i] += Offset(0, -top * wallK); - if (bottom < 0) forces[i] += Offset(0, bottom * wallK); - - for (var j = i + 1; j < list.length; j++) { - final b = list[j]; - var delta = b.pos - a.pos; - var dist = delta.distance; - if (dist < 0.01) { - // exakt übereinander: in eine feste Richtung auseinander - delta = Offset(math.cos(i + j * 1.7), math.sin(i + j * 1.7)); - dist = 0.01; - } - final rest = (a.r + b.r) * (1 - overlap); - if (dist < rest) { - final u = delta / dist; - final f = u * ((rest - dist) * pushK); - forces[i] -= f; - forces[j] += f; - } - } - } - - var moving = false; - final decay = math.exp(-damping * dt); - for (var i = 0; i < list.length; i++) { - final b = list[i]; - b.vel = (b.vel + forces[i] * dt) * decay; - b.pos += b.vel * dt; - // Größe federt zum Ziel (leichtes Überschwingen beim Erscheinen). - b.rVel += (radiusK * (b.targetR - b.r) - radiusDamping * b.rVel) * dt; - b.r = math.max(0, b.r + b.rVel * dt); - if (b.vel.distance > 2 || - b.rVel.abs() > 2 || - (b.targetR - b.r).abs() > 0.5) { - moving = true; - } - } - return moving; - } - - /// Umriss von [id] in Bühnen-Koordinaten: der Kreis, an jeder Berührung - /// mit einem Nachbarn gerade abgeschnitten (mit [seam] Pixel Fuge). - Path clipPath(String id, {double seam = 3}) { - final a = bodies[id]!; - final circle = Rect.fromCircle(center: a.pos, radius: math.max(a.r, 0.01)); - var path = Path()..addOval(circle); - for (final b in bodies.values) { - if (identical(a, b) || b.r < 1) continue; - final delta = b.pos - a.pos; - final d = delta.distance; - if (d < 0.01 || d >= a.r + b.r) continue; - final u = delta / d; - // Abstand von a zur Potenzgerade der beiden Kreise - final cut = (d * d + a.r * a.r - b.r * b.r) / (2 * d) - seam / 2; - if (cut >= a.r) continue; - final n = Offset(-u.dy, u.dx); - final big = a.r * 4 + 10; - final base = a.pos + u * cut; - final half = Path() - ..addPolygon([ - base + n * big, - base - n * big, - base - n * big - u * big, - base + n * big - u * big, - ], true); - path = Path.combine(PathOperation.intersect, path, half); - } - return path; - } -} diff --git a/lib/features/call_ui/inflated_layout.dart b/lib/features/call_ui/inflated_layout.dart new file mode 100644 index 0000000..680f793 --- /dev/null +++ b/lib/features/call_ui/inflated_layout.dart @@ -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 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 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 ids, Set 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 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 _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(0, (s, c) => s + c.area); + final shareSum = cells.values.fold(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(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 = [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 _clip(List poly, Offset n, double limit) { + final out = []; + 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 = []; + 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); + } +} diff --git a/lib/features/call_ui/view/bubble_stage.dart b/lib/features/call_ui/view/bubble_stage.dart index a21ae80..ff20cba 100644 --- a/lib/features/call_ui/view/bubble_stage.dart +++ b/lib/features/call_ui/view/bubble_stage.dart @@ -1,8 +1,10 @@ part of '../voice_channel.dart'; -/// Anrufansicht als aneinandergedrückte Bubbles (Physik: bubble_physics.dart). -/// Jede Person ist eine Blase mit Profilbild (oder Kamera-Bild), wer spricht, -/// wächst und leuchtet; Blasen weichen sich federnd aus. +/// Anrufansicht als „aufgeblasene“ Formen (Layout: inflated_layout.dart, +/// Bernd 2026-10-10): Jede Person füllt ihre Zelle bis an die Nachbarn und +/// den Fensterrand, dargestellt als Kontur mit weichem Innenleuchten; in der +/// Mitte Profilbild und Name. Wer spricht, pumpt sich auf und leuchtet grün, +/// die anderen geben federnd nach. Kamera-Bild füllt die Form. class _BubbleStage extends StatefulWidget { final List<_ParticipantData> participants; final Client? client; @@ -23,7 +25,7 @@ class _BubbleStage extends StatefulWidget { class _BubbleStageState extends State<_BubbleStage> with SingleTickerProviderStateMixin { - final _sim = BubbleSim(); + final _layout = InflatedLayout(); // Letzter bekannter Stand je Person – auch für Gehende, die noch // wegschrumpfen. final Map _data = {}; @@ -50,8 +52,8 @@ class _BubbleStageState extends State<_BubbleStage> : (elapsed - _last!).inMicroseconds / Duration.microsecondsPerSecond; _last = elapsed; _time += dt; - final moving = _sim.step(dt); - _data.removeWhere((id, _) => !_sim.bodies.containsKey(id)); + final moving = _layout.step(dt); + _data.removeWhere((id, _) => !_layout.cells.containsKey(id)); final speaking = widget.participants.any((p) => p.isSpeaking); setState(() {}); // Ruhe = keine Animation (spart Akku); Sprechen hält das Leuchten an. @@ -65,7 +67,7 @@ class _BubbleStageState extends State<_BubbleStage> for (final p in widget.participants) { _data[p.id] = p; } - _sim.sync( + _layout.sync( widget.participants.map((p) => p.id).toList(), widget.participants.where((p) => p.isSpeaking).map((p) => p.id).toSet(), size, @@ -81,155 +83,146 @@ class _BubbleStageState extends State<_BubbleStage> final pt = widget.pt; return LayoutBuilder(builder: (context, box) { final size = box.biggest; - if (_needsSync || size != _sim.size) { + if (_needsSync || size != _layout.size) { _needsSync = false; _sync(size); } - final glow = 0.5 + 0.5 * math.sin(_time * 6); - final children = []; - for (final b in _sim.bodies.values) { - final d = _data[b.id]; - if (d == null || b.r < 2) continue; - final rect = Rect.fromCircle(center: b.pos, radius: b.r); - children.add(Positioned.fromRect( - key: ValueKey('bubble:${b.id}'), - rect: rect, - child: _Bubble( - data: d, - path: _sim.clipPath(b.id).shift(-rect.topLeft), - radius: b.r, - client: widget.client, - pt: pt, - glow: glow, - onTap: () => widget.onSelect(d), - ), + final glow = 0.5 + 0.5 * math.sin(_time * 5); + final shapes = <_CellShape>[]; + for (final c in _layout.cells.values) { + final d = _data[c.id]; + if (d == null || c.area < 16) continue; + shapes.add(_CellShape( + data: d, + path: _layout.outline(c.id), + bounds: _layout.cellBounds(c.id), + center: c.centroid, + opacity: c.share.clamp(0.0, 1.0), )); } - return DecoratedBox( - decoration: BoxDecoration( - gradient: RadialGradient( - colors: [pt.bg2, pt.bg1], - radius: 0.9, - ), + + return ColoredBox( + color: pt.bg1, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + // Kamera-Bild füllt die Form. + for (final s in shapes) + if (s.data.hasVideo && s.data.coverVideoWidget != null) + Positioned.fill( + key: ValueKey('video:${s.data.id}'), + child: ClipPath( + clipper: _PathClipper(s.path), + child: Stack(children: [ + Positioned.fromRect( + rect: s.bounds, + child: _MirrorWrapper( + isLocal: s.data.isLocal, + isScreenShare: s.data.isScreenShare, + child: s.data.coverVideoWidget!, + ), + ), + ]), + ), + ), + // Konturen (alle in einem Zeichenvorgang). + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: _ContourPainter( + shapes: shapes, + glow: glow, + speakingColor: pt.online, + ), + ), + ), + ), + // Profilbild + Name in der Mitte jeder Form. + for (final s in shapes) + Positioned.fromRect( + key: ValueKey('content:${s.data.id}'), + rect: Rect.fromCenter( + center: s.data.hasVideo + ? Offset(s.center.dx, s.bounds.bottom - 28) + : s.center, + width: s.bounds.width * 0.86, + height: s.data.hasVideo ? 40 : s.bounds.height * 0.82, + ), + child: IgnorePointer( + child: Opacity( + opacity: s.opacity, + child: _CellContent( + data: s.data, + client: widget.client, + pt: pt, + cellSize: s.bounds.size, + ), + ), + ), + ), + // Antippen: Form mit Kamera-Bild groß zeigen. + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTapUp: (details) { + for (final s in shapes) { + if (s.path.contains(details.localPosition)) { + widget.onSelect(s.data); + return; + } + } + }, + ), + ), + ], ), - child: Stack(clipBehavior: Clip.hardEdge, children: children), ); }); } } -class _Bubble extends StatelessWidget { +class _CellShape { final _ParticipantData data; final Path path; - final double radius; - final Client? client; - final PyramidTheme pt; - final double glow; - final VoidCallback onTap; - - const _Bubble({ + final Rect bounds; + final Offset center; + final double opacity; + const _CellShape({ required this.data, required this.path, - required this.radius, + required this.bounds, + required this.center, + required this.opacity, + }); +} + +/// Profilbild (rund, mit Kontur) und Name – skaliert sich in jede Zellgröße. +class _CellContent extends StatelessWidget { + final _ParticipantData data; + final Client? client; + final PyramidTheme pt; + final Size cellSize; + + const _CellContent({ + required this.data, required this.client, required this.pt, - required this.glow, - required this.onTap, + required this.cellSize, }); @override Widget build(BuildContext context) { - final showName = radius > 38; - return Semantics( - label: data.isMuted ? '${data.name}, stumm' : data.name, - button: data.hasVideo, - child: GestureDetector( - onTap: onTap, - child: CustomPaint( - foregroundPainter: _BubbleRingPainter( - path: path, - speaking: data.isSpeaking, - glow: glow, - color: pt.online, - ), - child: ClipPath( - clipper: _PathClipper(path), - child: Stack( - fit: StackFit.expand, - children: [ - _face(), - if (data.hasVideo && data.coverVideoWidget != null) - _MirrorWrapper( - isLocal: data.isLocal, - isScreenShare: data.isScreenShare, - child: data.coverVideoWidget!, - ), - const IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: RadialGradient( - center: Alignment(-0.45, -0.55), - radius: 0.95, - colors: [Color(0x2EFFFFFF), Color(0x00FFFFFF)], - ), - ), - ), - ), - if (showName) - Align( - alignment: const Alignment(0, 0.62), - child: _nameTag(), - ), - ], - ), - ), - ), - ), - ); - } - - /// Profilbild oder Farbverlauf mit Anfangsbuchstaben – fest in 256 px - /// gebaut und skaliert, damit das Bild beim Wachsen nicht neu lädt. - Widget _face() { - Widget initial(BuildContext _) => Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [data.avatarColor, data.avatarColor.withAlpha(150)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - alignment: const Alignment(0, -0.12), - child: Text(data.initial, - style: const TextStyle( - color: Colors.white, - fontSize: 104, - fontWeight: FontWeight.w700)), - ); - final c = client; - final face = (c != null && data.avatarUrl != null) - ? MxcAvatar( - mxcUri: data.avatarUrl, - client: c, - size: 256, - borderRadius: BorderRadius.zero, - placeholder: initial, - ) - : Builder(builder: initial); - return FittedBox( - fit: BoxFit.cover, - child: SizedBox(width: 256, height: 256, child: face), - ); - } - - Widget _nameTag() { - final fontSize = (radius * 0.16).clamp(10.0, 15.0); - return Container( - constraints: BoxConstraints(maxWidth: radius * 1.5), + final avatar = (math.min(cellSize.width, cellSize.height) * 0.36) + .clamp(28.0, 132.0) + .toDouble(); + final fontSize = (avatar * 0.15).clamp(11.0, 16.0).toDouble(); + final nameTag = Container( padding: EdgeInsets.symmetric( horizontal: fontSize * 0.7, vertical: fontSize * 0.25), decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.45), + color: data.hasVideo + ? Colors.black.withValues(alpha: 0.5) + : Colors.transparent, borderRadius: BorderRadius.circular(999), ), child: Row( @@ -239,21 +232,91 @@ class _Bubble extends StatelessWidget { Icon(Icons.mic_off_rounded, size: fontSize + 1, color: pt.danger), SizedBox(width: fontSize * 0.3), ], - Flexible( - child: Text( - data.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Colors.white, - fontSize: fontSize, - fontWeight: FontWeight.w600, - ), + Text( + data.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: data.hasVideo ? Colors.white : pt.fg, + fontSize: fontSize, + fontWeight: FontWeight.w600, ), ), ], ), ); + if (data.hasVideo) { + return FittedBox(fit: BoxFit.scaleDown, child: nameTag); + } + return FittedBox( + fit: BoxFit.scaleDown, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _OutlinedAvatar( + data: data, + client: client, + size: avatar, + ring: data.isSpeaking ? pt.online : data.avatarColor, + ), + SizedBox(height: fontSize * 0.6), + nameTag, + ], + ), + ); + } +} + +/// Rundes Profilbild mit Kontur; ohne Bild nur der Anfangsbuchstabe in der +/// Konturfarbe (passt zum reinen Linien-Look). +class _OutlinedAvatar extends StatelessWidget { + final _ParticipantData data; + final Client? client; + final double size; + final Color ring; + + const _OutlinedAvatar({ + required this.data, + required this.client, + required this.size, + required this.ring, + }); + + @override + Widget build(BuildContext context) { + Widget letter(BuildContext _) => Container( + alignment: Alignment.center, + color: ring.withValues(alpha: 0.08), + // Gezeichnet in der 256er-Fläche, danach auf [size] skaliert. + child: Text(data.initial, + style: TextStyle( + color: ring, fontSize: 104, fontWeight: FontWeight.w600)), + ); + final c = client; + final inner = (c != null && data.avatarUrl != null) + ? MxcAvatar( + mxcUri: data.avatarUrl, + client: c, + size: 256, + borderRadius: BorderRadius.zero, + placeholder: letter, + ) + : Builder(builder: letter); + return Container( + width: size, + height: size, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: ring, width: 2), + ), + child: ClipOval( + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox(width: 256, height: 256, child: inner), + ), + ), + ); } } @@ -268,60 +331,71 @@ class _PathClipper extends CustomClipper { bool shouldReclip(_PathClipper old) => !identical(old.path, path); } -class _BubbleRingPainter extends CustomPainter { - final Path path; - final bool speaking; +class _ContourPainter extends CustomPainter { + final List<_CellShape> shapes; final double glow; - final Color color; + final Color speakingColor; - _BubbleRingPainter({ - required this.path, - required this.speaking, + _ContourPainter({ + required this.shapes, required this.glow, - required this.color, + required this.speakingColor, }); @override void paint(Canvas canvas, Size size) { - if (speaking) { + for (final s in shapes) { + final speaking = s.data.isSpeaking; + final color = speaking ? speakingColor : s.data.avatarColor; + final o = s.opacity; + // Weiches Leuchten innen an der Kontur – wirkt „prall“, bleibt aber + // ein Linien-Look. + canvas.save(); + canvas.clipPath(s.path); canvas.drawPath( - path, + s.path, Paint() - ..color = color.withValues(alpha: 0.30 + 0.30 * glow) ..style = PaintingStyle.stroke - ..strokeWidth = 7 - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6), + ..strokeWidth = speaking ? 30 : 22 + ..color = color.withValues( + alpha: o * (speaking ? 0.20 + 0.12 * glow : 0.10)) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12), ); + canvas.restore(); + if (speaking) { + canvas.drawPath( + s.path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 8 + ..color = color.withValues(alpha: o * (0.25 + 0.25 * glow)) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8), + ); + } canvas.drawPath( - path, + s.path, Paint() - ..color = color ..style = PaintingStyle.stroke - ..strokeWidth = 3 - ..strokeJoin = StrokeJoin.round, - ); - } else { - canvas.drawPath( - path, - Paint() - ..color = Colors.white.withValues(alpha: 0.10) - ..style = PaintingStyle.stroke - ..strokeWidth = 1.2, + ..strokeWidth = speaking ? 3 : 2 + ..strokeJoin = StrokeJoin.round + ..color = color.withValues(alpha: o * (speaking ? 1.0 : 0.85)), ); } } @override - bool shouldRepaint(_BubbleRingPainter old) => true; + bool shouldRepaint(_ContourPainter old) => true; } -/// Vorführ-Ansicht der Bubbles mit erfundenen Teilnehmern – nur für +/// Vorführ-Ansicht der Anruf-Formen mit erfundenen Teilnehmern – nur für /// Screenshots/Tests (UI-Tour), damit man die Ansicht mit mehreren Personen /// sieht, ohne dass so viele Testkonten anrufen müssen. @visibleForTesting class CallBubblesDemo extends StatelessWidget { final int count; - const CallBubblesDemo({super.key, this.count = 5}); + /// Wer gerade spricht (Index, -1 = niemand). + final int speaking; + const CallBubblesDemo({super.key, this.count = 5, this.speaking = 2}); static const _names = ['Du', 'Anna', 'Ben', 'Clara', 'David', 'Ela', 'Finn']; @@ -334,7 +408,7 @@ class CallBubblesDemo extends StatelessWidget { id: 'demo$i', name: _names[i], isLocal: i == 0, - isSpeaking: i == 2, + isSpeaking: i == speaking, isMuted: i == 3, avatarColor: VoiceChannelView._avatarColors[i % VoiceChannelView._avatarColors.length], ), diff --git a/lib/features/call_ui/voice_channel.dart b/lib/features/call_ui/voice_channel.dart index f1cd2b5..77f26ea 100644 --- a/lib/features/call_ui/voice_channel.dart +++ b/lib/features/call_ui/voice_channel.dart @@ -9,7 +9,7 @@ import 'package:livekit_client/livekit_client.dart'; import 'package:collection/collection.dart'; import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/matrix_client.dart'; -import 'package:pyramid/features/call_ui/bubble_physics.dart'; +import 'package:pyramid/features/call_ui/inflated_layout.dart'; import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/spring_press.dart' show motionDuration; import 'package:pyramid/widgets/screen_share_picker.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 7eade84..d39cf48 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pyramid description: A flexible Matrix client with resizable panel layout. publish_to: none -version: 0.1.42+43 +version: 0.1.43+44 environment: sdk: ">=3.11.1 <4.0.0" diff --git a/test/bubble_physics_test.dart b/test/bubble_physics_test.dart deleted file mode 100644 index df4bfe8..0000000 --- a/test/bubble_physics_test.dart +++ /dev/null @@ -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)); - }); -} diff --git a/test/inflated_layout_test.dart b/test/inflated_layout_test.dart new file mode 100644 index 0000000..d7285a1 --- /dev/null +++ b/test/inflated_layout_test.dart @@ -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 _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(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'); + }); +}