Files
pyramid/lib/features/call_ui/bubble_physics.dart
T
Bernd SteckmeisterandClaude Opus 5.5 391ebf0ffa feat(ui): untere Leiste (Chats/Server/Du) als Option, Server-Uebersicht, Du-Tab, Feinschliff
- Einstellung 'Navigation: Seitenmenue | Untere Leiste' (Handy, Standard
  bleibt Seitenmenue): Tabs Chats/Server/Du, Server-Uebersicht als Karten,
  Du-Tab mit Profil + Einstellungen, Zurueck-Taste ueber die Tabs
- CallDock als eigenes Widget (Anrufleiste auch ueber der Tab-Leiste)
- Sprachkanaele am Handy als Kachel mit 'wer ist drin'
- Suchfeld mit Lupe, 'Kanaele durchsuchen' im Server, Filter-Chips am PC
  umbrechend, '#' nur bei Server-Kanaelen, englische Reste entfernt
- Anrufansicht merkt sich Bubbles/Kacheln, Bubbles mit leichtem Glanz
- UI-Tour: Gruppenanruf (echter zweiter LiveKit-Teilnehmer), Bubbles mit
  5/7 Personen, untere Leiste; Version 0.1.42

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 14:22:02 +02:00

202 lines
7.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}