- 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]>
202 lines
7.1 KiB
Dart
202 lines
7.1 KiB
Dart
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;
|
||
}
|
||
}
|