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
-201
View File
@@ -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<String, BubbleBody> 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<String> ids, Set<String> 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<String> ids, Set<String> 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<double>(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<Offset>.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;
}
}
+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);
}
}
+245 -171
View File
@@ -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<String, _ParticipantData> _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 = <Widget>[];
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<Path> {
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],
),
+1 -1
View File
@@ -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';