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:
co-authored by
Claude Opus 5.5
parent
87b472a373
commit
de24053167
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user