feat(ui): gemeinsame Chatliste, Gruppenanrufe mit Beitreten-Karte, Bubble-Anrufansicht

- Chats: Direktnachrichten + Gruppen ohne Server zusammen, Filter-Chips
  (Alle/Ungelesen/Personen/Gruppen), Leerzustaende mit Handlung; virtuelles
  'Raeume'-Symbol entfaellt, Server-Kanaele nur noch im Server
- Gruppen: runder Gruppen-Avatar, Kopf mit Mitgliederzahl, Anruf-/Video-Knopf
- Gruppenanruf: LiveKit + Anwesenheit, Start-Nachricht (Push), Karte
  'Gruppenanruf laeuft · Beitreten', Hinweis-Karte im Verlauf
- Anrufansicht: aneinandergedrueckte Bubbles (Federphysik, Sprecher waechst),
  Kacheln umschaltbar, Bildschirm automatisch gross, Gespraechsdauer,
  Matrix-Namen/-Avatare statt LiveKit-IDs
- Tests: test/bubble_physics_test.dart

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-09 11:58:31 +02:00
co-authored by Claude Opus 5.5
parent 87b472a373
commit de24053167
24 changed files with 1550 additions and 314 deletions
+201
View File
@@ -0,0 +1,201 @@
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.30;
} else {
// Kreise sollen zusammen gut 40 % der Fläche bedecken.
final sumW2 = active.fold<double>(0, (s, id) => s + weight(id) * weight(id));
base = math.sqrt(0.42 * w * h / (math.pi * sumW2));
}
final maxR = shortSide * (active.length <= 2 ? 0.30 : 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;
}
}