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
+94
View File
@@ -0,0 +1,94 @@
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:pyramid/features/call_ui/bubble_physics.dart';
/// Lässt die Simulation [seconds] lang laufen (60 Schritte pro Sekunde).
bool _run(BubbleSim sim, double seconds) {
var moving = true;
for (var i = 0; i < (seconds * 60).round(); i++) {
moving = sim.step(1 / 60);
}
return moving;
}
void main() {
const phone = Size(390, 620);
const desktop = Size(1200, 700);
test('zwei Bubbles drücken sich leicht aneinander und kommen zur Ruhe', () {
final sim = BubbleSim()..sync(['a', 'b'], {}, desktop);
final moving = _run(sim, 6);
expect(moving, isFalse, reason: 'Simulation kommt nicht zur Ruhe');
final a = sim.bodies['a']!, b = sim.bodies['b']!;
final dist = (a.pos - b.pos).distance;
// Sie berühren sich (überlappen leicht) statt frei zu schweben …
expect(dist, lessThan(a.r + b.r));
// … liegen aber nicht ineinander.
expect(dist, greaterThan((a.r + b.r) * 0.8));
// Querformat: nebeneinander statt übereinander
expect((a.pos.dy - b.pos.dy).abs(), lessThan((a.pos.dx - b.pos.dx).abs()));
});
test('wer spricht, wird größer', () {
final sim = BubbleSim()..sync(['a', 'b', 'c'], {}, phone);
_run(sim, 4);
final before = sim.bodies['b']!.r;
sim.sync(['a', 'b', 'c'], {'b'}, phone);
_run(sim, 4);
final b = sim.bodies['b']!.r;
expect(b, greaterThan(before * 1.1));
expect(b, greaterThan(sim.bodies['a']!.r * 1.1));
});
test('viele Teilnehmer bleiben im Bild (Handy hochkant)', () {
final ids = [for (var i = 0; i < 8; i++) 'p$i'];
final sim = BubbleSim()..sync(ids, {}, phone);
_run(sim, 8);
for (final b in sim.bodies.values) {
expect(b.pos.dx - b.r, greaterThan(-2), reason: '${b.id} links raus');
expect(b.pos.dx + b.r, lessThan(phone.width + 2), reason: '${b.id} rechts raus');
expect(b.pos.dy - b.r, greaterThan(-2), reason: '${b.id} oben raus');
expect(b.pos.dy + b.r, lessThan(phone.height + 2), reason: '${b.id} unten raus');
}
});
test('Gehende schrumpfen weg und verschwinden', () {
final sim = BubbleSim()..sync(['a', 'b'], {}, phone);
_run(sim, 3);
sim.sync(['a'], {}, phone);
_run(sim, 3);
expect(sim.bodies.keys, ['a']);
});
test('Berührungsfläche ist abgeflacht, freier Rand bleibt rund', () {
final sim = BubbleSim()..sync(['a', 'b'], {}, desktop);
_run(sim, 6);
final a = sim.bodies['a']!, b = sim.bodies['b']!;
final bounds = sim.clipPath('a').getBounds();
final toB = (b.pos - a.pos);
// In Richtung des Nachbarn endet die Bubble vor ihrem Kreisrand …
if (toB.dx > 0) {
expect(bounds.right, lessThan(a.pos.dx + a.r - 1));
// … auf der freien Seite reicht sie bis zum Kreisrand.
expect(bounds.left, closeTo(a.pos.dx - a.r, 1));
} else {
expect(bounds.left, greaterThan(a.pos.dx - a.r + 1));
expect(bounds.right, closeTo(a.pos.dx + a.r, 1));
}
// Mittelpunkt liegt im Umriss, der Nachbar-Mittelpunkt nicht.
expect(sim.clipPath('a').contains(a.pos), isTrue);
expect(sim.clipPath('a').contains(b.pos), isFalse);
});
test('einzelne Bubble: rund, mittig', () {
final sim = BubbleSim()..sync(['solo'], {}, phone);
_run(sim, 4);
final s = sim.bodies['solo']!;
expect(s.pos.dx, closeTo(phone.width / 2, 2));
expect(s.pos.dy, closeTo(phone.height / 2, 2));
final bounds = sim.clipPath('solo').getBounds();
expect(bounds.width, closeTo(s.r * 2, 1));
});
}