Compare commits

...
3 Commits
Author SHA1 Message Date
Bernd SteckmeisterandClaude Opus 5.5 1119c83af6 docs: aufgeblasene Anruf-Formen und 0.1.43 protokolliert
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 14:58:46 +02:00
Bernd SteckmeisterandClaude Opus 5.5 ab84701e73 feat(call): aufgeblasene Formen statt runder Bubbles (Bernds Referenzbild)
- Neues Layout lib/features/call_ui/inflated_layout.dart: gewichtetes
  Voronoi-/Potenzdiagramm im Anrufbereich, Zellen fuellen das Fenster bis an
  die Raender, an Nachbarn plattgedrueckt, weich gerundete Ecken;
  Lloyd-Relaxation + sanfter Zug ins Raster (aufgeraeumt), Flaechen per
  Gewicht nachgefuehrt (Sprecher 1,7-fach), Neue pumpen sich auf, Gehende
  schrumpfen weg, je mehr Personen desto kleiner
- Darstellung nur als Kontur mit weichem Innenleuchten, Profilbild mit
  Kontur + Name in der Mitte, Sprecher gruen pulsierend, Kamera-Bild
  fuellt die Form
- Tests: test/inflated_layout_test.dart (7), Vorschau-/Animationsbilder
  integration_test/call_bubbles_demo_test.dart; alte Kugel-Physik entfernt
- Version 0.1.43

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 14:54:45 +02:00
Bernd SteckmeisterandClaude Opus 5.5 d4473c7d64 docs: UI-Neuanstrich und Test-Version 0.1.42 protokolliert
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 14:32:03 +02:00
10 changed files with 894 additions and 471 deletions
+75
View File
@@ -13,6 +13,81 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
--- ---
## 2026-10-10 (Teil 2) – Anrufansicht als „aufgeblasene“ Formen → 0.1.43
**Erledigt (ab84701, Gitea-Vorabversion v0.1.43):** Bernd wollte statt runder
Kugeln Formen, die „in die Grenzen ihres Fensters aufgeblasen“ sind (Referenz:
aufgepumpte Buchstaben in einer Kiste), vorerst nur als Kontur, animiert,
kleiner je mehr Leute. Umgesetzt mit `lib/features/call_ui/inflated_layout.dart`
(Potenzdiagramm im Anrufbereich, Lloyd + sanfter Zug ins Raster, Flächen per
Gewicht nachgeführt, Sprecher 1,7-fach, Neue pumpen sich aus winzig auf,
Gehende schrumpfen weg, gerundete Ecken) und `view/bubble_stage.dart`
(Kontur + Innenleuchten, Profilbild mit Kontur + Name, Sprecher grün,
Kamera füllt die Form). Tests: `test/inflated_layout_test.dart` (7);
Vorschaubilder + Animations-GIF: `integration_test/call_bubbles_demo_test.dart`
→ `build/call_demo/` (GIF per scratchpad `make_gif.py`). Alte Kugel-Physik
entfernt. Analyse + 76 Unit-Tests + UI-Tour grün.
**Stolperfalle:** Multiplikative Gewichtsanpassung (Voronoi-Treemap-Rezept)
startet bei Gewicht ≈ 0 extrem langsam → Sprecher wurde erst nach Sekunden
größer. Additiv (Gewicht += Flächenfehler, gedämpft) konvergiert in ~1 s.
**Offen:** Bernds Urteil zum neuen Look (Glanz/Füllung später?) und zur
Navigation; Gruppenanruf auf echten Geräten.
---
## 2026-10-10 – UI-Neuanstrich: Chats, Gruppenanrufe, Bubbles, untere Leiste → 0.1.42
**Erledigt:**
- **UI-Audit** (87b472a, `docs/UI_AUDIT.md`): Navigationskarte, Sackgassen
S1–S6 behoben (Anruf endet → zurück zum Chat statt englischer Leerseite,
Fehler sichtbar, Threads-Knopf weg, Space öffnet ersten Kanal am PC, keine
doppelte Anrufsteuerung), englische Reste, Annehmen-Fenster mit Avatar.
Screenshot-Tour `integration_test/ui_audit_tour_test.dart` (Handy + PC).
- **Bernds Entscheidungen (2026-10-09) umgesetzt** (de24053, 391ebf0):
- „Alles in Chats“: DMs + Gruppen ohne Server in einer Liste nach
Aktivität, Filter-Chips, Leerzustände mit Handlung; virtuelles „Räume“
entfällt, Server-Kanäle nur im Server (`core/room_kinds.dart`).
- Gruppenanruf (`features/group_call/group_call.dart`): Anruf-/Video-Knopf
im Gruppenkopf, LiveKit + Anwesenheit (`io.pyramid.voice.presence`),
Start-Nachricht als m.text (Push; m.notice wäre stumm), Karte
„Gruppenanruf läuft · Beitreten“, Hinweis-Karte im Verlauf. Berechtigung
für die Anwesenheit wird bei Bedarf gesenkt (nur wer Rechte ändern darf;
neue Gruppen beim Erstellen).
- Bubble-Anrufansicht (`call_ui/bubble_physics.dart` + `view/bubble_stage.dart`):
Federphysik, Abflachung an der Potenzgerade, Sprecher wächst + leuchtet,
Kacheln umschaltbar (gemerkt), Bildschirm automatisch groß,
Gesprächsdauer, Matrix-Namen/-Avatare statt LiveKit-IDs. Unit-Test
`test/bubble_physics_test.dart`.
- Untere Leiste als Option (`layout/mobile_tabs.dart`, Einstellung
Erscheinungsbild → Navigation; Standard bleibt Seitenmenü): Chats ·
Server (Übersicht als Karten) · Du (Profil + Einstellungen).
- Gruppen mit rundem Avatar + Mitgliederzahl, Sprachkanal-Kacheln mit
„wer ist drin“, Suchfeld-Lupe, „#“ nur bei Kanälen.
- Vergleichsbilder für Bernd: `build/ui_vergleich/` (aus der Tour).
- Tests: `scripts/test.ps1 -Ui` grün (Analyse, 75 Unit, Handy-UI, Live
Lesebestätigung/Einladung, Live-Anruf); Tour grün inkl. echtem zweitem
LiveKit-Teilnehmer im Gruppenanruf.
- Gitea-Vorabversion **v0.1.42** (Signatur = v0.1.36), PC-Testinstanz in
`pyramid-testinstanzpp-neu` bereitgelegt.
**Offen/Nächster Schritt:** Bernds Urteil Seitenmenü vs. untere Leiste;
Gruppenanruf auf echten Geräten (Handy ↔ PC-Testinstanz); Rest aus
UI_AUDIT §4 (Schnackbar-Stil, kompaktere Anruf-Pille).
**Stolperfallen:**
- `pumpFor` zeichnet im Integrationstest nur alle 100 ms → Physik/Animationen
kommen nicht zur Ruhe; Tour pumpt für Screenshots mit 16 ms.
- Zustand der Anrufansicht geht beim Wechsel Handy↔PC-Layout verloren →
Ansichtswahl statisch gemerkt.
- `addTearDown` darf nach dem Test keinen Provider mehr lesen (Container
schon entsorgt).
- Bash-Python-Patches: Ausgabe von Emojis scheitert an cp1252 (Datei wird
trotzdem geschrieben).
---
## 2026-10-08 (Teil 3) – Taub/Stumm, Mini-Leiste, HDR-Befund → 0.1.41 ## 2026-10-08 (Teil 3) – Taub/Stumm, Mini-Leiste, HDR-Befund → 0.1.41
**Erledigt (f46cc22, Version 5204bb8, Gitea-Vorabversion v0.1.41):** Bernd hört **Erledigt (f46cc22, Version 5204bb8, Gitea-Vorabversion v0.1.41):** Bernd hört
+10 -3
View File
@@ -108,7 +108,14 @@ S1–S6, englische Reste in Anruf/Leerzuständen, Annehmen-Fenster mit Avatar
und Anrufart, eigene Kachel mit eigenem Avatar, „Du“ in der Mitgliederliste, und Anrufart, eigene Kachel mit eigenem Avatar, „Du“ in der Mitgliederliste,
„Tastaturkürzel“ nur am PC – Details in PROGRESS.md. „Tastaturkürzel“ nur am PC – Details in PROGRESS.md.
## 6. Offene Richtungsentscheidungen (Bernd) ## 6. Bernds Entscheidungen (2026-10-09) und Umsetzung (0.1.42)
Navigation Handy (Tabs vs. Seitenmenü), Chats + Gruppen zusammen, Stil der | Frage | Entscheidung | Umsetzung |
Anrufansicht, Anruf-Pille – siehe Fragen im Chat. |---|---|---|
| Navigation Handy | Seitenmenü; untere Leiste als Mockup zum Vergleich | Einstellung „Navigation“ (Standard Seitenmenü), `layout/mobile_tabs.dart`, Vergleichsbild `build/ui_vergleich/1_navigation_vergleich.png` |
| Chatliste | Alles in „Chats“ | DMs + Gruppen ohne Server zusammen, Filter-Chips; Server-Kanäle nur im Server |
| Gruppenanruf | Anruf + Beitreten-Karte | Knopf im Gruppenkopf, Start-Nachricht (Push), Karte „Gruppenanruf läuft · Beitreten“ |
| Anrufansicht | animierte, aneinandergedrückte Bubbles → (2026-10-10) „aufgeblasene“ Formen bis an die Fensterränder, erst nur Kontur | `call_ui/inflated_layout.dart` + `view/bubble_stage.dart`, Kacheln umschaltbar, Vorschau `integration_test/call_bubbles_demo_test.dart` |
Noch offen aus §4: einheitliche Hinweis-Meldungen (Snackbar-Stil), kompaktere
Anruf-Pille, Abschnitts-Überschriften Text/Sprache in Server-Listen.
@@ -0,0 +1,111 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/call_ui/voice_channel.dart' show CallBubblesDemo;
/// Vorschaubilder der Anrufansicht mit erfundenen Teilnehmern (ohne Login):
/// Standbilder für 1–7 Personen (Handy + PC) und eine Bildfolge der
/// Animation (Sprecherwechsel, jemand kommt dazu, jemand geht) für ein GIF.
/// Bilder: build/call_demo/.
///
/// flutter test integration_test/call_bubbles_demo_test.dart -d windows
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Anruf-Formen: Vorschaubilder', (tester) async {
final count = ValueNotifier(5);
final speaking = ValueNotifier(2);
void phone() => tester.view
..physicalSize = const Size(1080, 2340)
..devicePixelRatio = 2.625;
void desktop() => tester.view
..physicalSize = const Size(1600, 1000)
..devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
Future<void> smooth(Duration d) async {
final end = DateTime.now().add(d);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 16));
}
}
phone();
await tester.pumpWidget(PyramidTheme(
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: buildPyramidThemeData(ThemeVariant.soft),
home: ValueListenableBuilder<int>(
valueListenable: count,
builder: (_, n, _) => ValueListenableBuilder<int>(
valueListenable: speaking,
builder: (_, s, _) => CallBubblesDemo(count: n, speaking: s),
),
),
),
));
// ── Standbilder ──
for (final n in [1, 2, 3, 5, 7]) {
count.value = n;
speaking.value = n >= 3 ? 2 : -1;
phone();
await smooth(const Duration(milliseconds: 2500));
await _shot(tester, 'p_${n}_personen');
desktop();
await smooth(const Duration(milliseconds: 2500));
await _shot(tester, 'd_${n}_personen');
}
// ── Animation (Handy) ──
phone();
count.value = 4;
speaking.value = -1;
await smooth(const Duration(seconds: 3));
var frame = 0;
Future<void> record(Duration d) async {
final end = DateTime.now().add(d);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 16));
await _shot(tester, 'anim/f${(frame++).toString().padLeft(4, '0')}',
scale: 0.4, log: false);
}
}
await record(const Duration(milliseconds: 600));
speaking.value = 1; // Anna spricht
await record(const Duration(milliseconds: 1600));
speaking.value = 3; // Clara spricht
await record(const Duration(milliseconds: 1600));
speaking.value = -1;
count.value = 5; // David kommt dazu
await record(const Duration(milliseconds: 1800));
count.value = 4; // David geht wieder
await record(const Duration(milliseconds: 1800));
// ignore: avoid_print
print('Animation: $frame Bilder');
}, timeout: const Timeout(Duration(minutes: 6)));
}
Future<void> _shot(WidgetTester tester, String name,
{double scale = 1, bool log = true}) async {
final renderView = tester.binding.renderViews.first;
final layer = renderView.debugLayer! as OffsetLayer;
final physical = tester.view.physicalSize;
final image = await tester.binding.runAsync(
() => layer.toImage(Offset.zero & physical, pixelRatio: scale),
);
final bytes = await tester.binding.runAsync(
() => image!.toByteData(format: ui.ImageByteFormat.png),
);
final file = File('build/call_demo/$name.png');
file.parent.createSync(recursive: true);
file.writeAsBytesSync(bytes!.buffer.asUint8List());
// ignore: avoid_print
if (log) print('Bild $name');
}
-201
View File
@@ -1,201 +0,0 @@
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;
}
}
+344
View File
@@ -0,0 +1,344 @@
import 'dart:math' as math;
import 'dart:ui';
/// Layout der Anrufansicht als „aufgeblasene“ Formen (Bernd 2026-10-10,
/// Referenz: aufgepumpte Buchstaben in einer Kiste): Jede Person bekommt eine
/// Zelle, alle Zellen zusammen füllen den ganzen Anrufbereich bis an die
/// Ränder. Wo zwei Zellen aneinanderstoßen, sind sie plattgedrückt, außen
/// liegen sie am Fensterrand an, die Ecken sind weich gerundet ([outline]).
///
/// Technik: gewichtetes Voronoi-Diagramm (Potenzdiagramm) im Rechteck. Jede
/// Zelle hat einen Anteil an der Fläche ([InflatedCell.share], wer spricht
/// bekommt mehr). In jedem Schritt wandert der Mittelpunkt einer Zelle zu
/// ihrem Schwerpunkt (gleichmäßige, ruhige Formen) und ihr Gewicht wird so
/// nachgeführt, dass die Fläche dem Anteil entspricht. Weil das schrittweise
/// geschieht, sieht man es als federndes Auf- und Abpumpen. Je mehr Personen,
/// desto kleiner die Zellen.
class InflatedCell {
InflatedCell(this.id, this.site, this.weight, {this.share = 1});
final String id;
/// Mittelpunkt (Erzeuger) der Zelle.
Offset site;
/// Platz im Raster, zu dem die Zelle sanft gezogen wird (aufgeräumte
/// Anordnung); null = keiner (z. B. beim Gehen).
Offset? slot;
/// Gewicht im Potenzdiagramm (Einheit: Fläche, wie Radius²).
double weight;
/// Aktueller (animierter) und gewünschter Flächenanteil.
double share;
double targetShare = 1;
bool get leaving => targetShare == 0;
/// Ergebnis der letzten Berechnung.
List<Offset> polygon = const [];
double area = 0;
Offset centroid = Offset.zero;
}
class InflatedLayout {
InflatedLayout({this.gap = 10, this.margin = 12});
/// Wie viel mehr Fläche bekommt, wer gerade spricht.
static const speakingShare = 1.7;
/// Abstand zwischen zwei Zellen bzw. zum Fensterrand.
final double gap;
final double margin;
final Map<String, InflatedCell> cells = {};
Size _size = Size.zero;
Size get size => _size;
Rect get bounds => Rect.fromLTWH(
margin,
margin,
math.max(0, _size.width - 2 * margin),
math.max(0, _size.height - 2 * margin),
);
/// Gleicht die Zellen mit den Teilnehmern ab ([speaking] = wer spricht).
void sync(List<String> ids, Set<String> speaking, Size size) {
final old = _size;
_size = size;
if (size != old && old.width > 0 && old.height > 0 && cells.isNotEmpty) {
final flipped = (old.width >= old.height) != (size.width >= size.height);
if (flipped) {
// Hochkant ↔ quer: neu anordnen, sonst bleiben z. B. zwei Zellen
// übereinander, obwohl nebeneinander viel besser passt.
_placeOnGrid(cells.values.toList());
} else {
final sx = size.width / old.width, sy = size.height / old.height;
for (final c in cells.values) {
c.site = Offset(c.site.dx * sx, c.site.dy * sy);
c.weight *= sx * sy;
}
}
}
final present = ids.toSet();
for (final c in cells.values) {
if (!present.contains(c.id)) c.targetShare = 0;
}
final fresh = ids.where((id) => !cells.containsKey(id)).toList();
if (cells.isEmpty) {
// Erster Aufbau: gleich voll da, im Raster angeordnet.
final created = [for (final id in fresh) InflatedCell(id, Offset.zero, 0)];
_placeOnGrid(created);
for (final c in created) {
cells[c.id] = c;
}
} else {
for (final id in fresh) {
// Neu dazu: entsteht in der größten Zelle und pumpt sich dort auf.
final host = cells.values.reduce((a, b) => a.area >= b.area ? a : b);
final h = id.hashCode;
final dir = Offset(math.cos(h % 360.0), math.sin(h % 360.0));
final spread = math.sqrt(math.max(host.area, 1)) * 0.2;
final site = _inside(host.centroid + dir * spread);
// Gewicht knapp über der Grenze, ab der die Zelle leer wäre
// (w ≤ w_host − d²): sie beginnt winzig und pumpt sich dann auf.
final d2 = (site - host.site).distanceSquared;
cells[id] = InflatedCell(
id,
site,
host.weight - d2 * 0.9,
share: 0.15,
);
}
}
for (final id in ids) {
cells[id]!.targetShare = speaking.contains(id) ? speakingShare : 1.0;
}
final slots = _gridPoints(ids.length);
for (var i = 0; i < ids.length; i++) {
cells[ids[i]]!.slot = i < slots.length ? slots[i] : null;
}
for (final c in cells.values) {
if (c.leaving) c.slot = null;
}
_compute();
}
void _placeOnGrid(List<InflatedCell> list) {
final points = _gridPoints(list.length);
for (var i = 0; i < list.length && i < points.length; i++) {
list[i]
..site = points[i]
..weight = 0;
}
}
/// Mittelpunkte eines Rasters für [n] Zellen: möglichst quadratische
/// Zellen, wenig Lücken; die letzte Reihe wird mittig verteilt.
List<Offset> _gridPoints(int n) {
final b = bounds;
if (n == 0 || b.isEmpty) return const [];
var cols = 1;
var best = double.infinity;
for (var c = 1; c <= n; c++) {
final r = (n / c).ceil();
final aspect = (b.width / c) / (b.height / r);
final score = math.log(aspect).abs() + 0.35 * (c * r - n);
if (score < best) {
best = score;
cols = c;
}
}
final rows = (n / cols).ceil();
return [
for (var i = 0; i < n; i++)
() {
final row = i ~/ cols;
final inRow = row == rows - 1 ? n - row * cols : cols;
final col = i % cols;
// Letzte (unvollständige) Reihe: gleichmäßig über die Breite.
final x = row == rows - 1 && inRow < cols
? b.left + b.width * (col + 0.5) / inRow
: b.left + b.width * (col + 0.5) / cols;
return Offset(x, b.top + b.height * (row + 0.5) / rows);
}(),
];
}
Offset _inside(Offset p) {
final b = bounds.deflate(1);
return Offset(p.dx.clamp(b.left, b.right), p.dy.clamp(b.top, b.bottom));
}
/// Ein Zeitschritt (Sekunden). `true`, solange sich noch etwas bewegt.
bool step(double dt) {
if (_size.isEmpty || cells.isEmpty) return false;
dt = dt.clamp(0.0, 1 / 20);
var moving = false;
final shareRate = 1 - math.exp(-dt * 6);
for (final c in cells.values) {
final d = c.targetShare - c.share;
if (d.abs() > 0.01) moving = true;
c.share += d * shareRate;
}
cells.removeWhere((_, c) => c.leaving && c.share < 0.03);
if (cells.isEmpty) return false;
_compute();
final total = cells.values.fold<double>(0, (s, c) => s + c.area);
final shareSum = cells.values.fold<double>(0, (s, c) => s + c.share);
final f = math.min(1.0, dt * 10);
final move = 1 - math.exp(-dt * 8);
for (final c in cells.values) {
// Fläche zum Anteil passend machen: Gewicht direkt um den Fehler
// verschieben (Gewicht und Fläche haben dieselbe Einheit; ein Plus an
// Gewicht schiebt alle Grenzen der Zelle nach außen). Gedämpft, damit
// es federnd statt ruckartig wirkt.
final target = total * c.share / shareSum;
final error = target - c.area;
if (error.abs() > target * 0.02) moving = true;
c.weight += 0.4 * f * error;
// … und den Mittelpunkt zu Schwerpunkt bzw. Rasterplatz ziehen.
if (c.area > 1) {
final goal = c.slot == null
? c.centroid
: Offset.lerp(c.centroid, c.slot!, 0.5)!;
final d = goal - c.site;
// Ruhe, sobald sich nichts mehr sichtbar bewegt (< ~3 px/s).
if (d.distance * move > 0.05) moving = true;
c.site = _inside(c.site + d * move);
}
}
// Nur Unterschiede zählen – Mittelwert bei 0 halten.
final mean =
cells.values.fold<double>(0, (s, c) => s + c.weight) / cells.length;
for (final c in cells.values) {
c.weight -= mean;
}
_clampWeights();
_compute();
return moving;
}
/// Ein Erzeuger muss in seiner eigenen Zelle liegen: w_j − w_i ≤ d².
void _clampWeights() {
final list = cells.values.toList();
for (var i = 0; i < list.length; i++) {
for (var j = 0; j < list.length; j++) {
if (i == j) continue;
final d2 = (list[i].site - list[j].site).distanceSquared;
if (list[j].weight - list[i].weight > d2) {
list[j].weight = list[i].weight + d2;
}
}
}
}
void _compute() {
final b = bounds;
final list = cells.values.toList();
for (final c in list) {
var poly = <Offset>[b.topLeft, b.topRight, b.bottomRight, b.bottomLeft];
for (final o in list) {
if (identical(o, c) || poly.isEmpty) continue;
final n = (o.site - c.site) * 2;
final len = n.distance;
if (len < 1e-6) continue;
// Grenze des Potenzdiagramms, um die halbe Lücke zu c hin versetzt.
final limit = o.site.distanceSquared -
c.site.distanceSquared +
c.weight -
o.weight -
gap / 2 * len;
poly = _clip(poly, n, limit);
}
c.polygon = poly;
_measure(c);
}
}
/// Polygon auf die Halbebene n·x ≤ limit beschneiden.
static List<Offset> _clip(List<Offset> poly, Offset n, double limit) {
final out = <Offset>[];
for (var i = 0; i < poly.length; i++) {
final a = poly[i], b = poly[(i + 1) % poly.length];
final da = n.dx * a.dx + n.dy * a.dy - limit;
final db = n.dx * b.dx + n.dy * b.dy - limit;
if (da <= 0) out.add(a);
if ((da <= 0) != (db <= 0)) {
out.add(a + (b - a) * (da / (da - db)));
}
}
return out;
}
static void _measure(InflatedCell c) {
final p = c.polygon;
if (p.length < 3) {
c.area = 0;
return;
}
var a2 = 0.0, cx = 0.0, cy = 0.0;
for (var i = 0; i < p.length; i++) {
final q = p[i], r = p[(i + 1) % p.length];
final cross = q.dx * r.dy - r.dx * q.dy;
a2 += cross;
cx += (q.dx + r.dx) * cross;
cy += (q.dy + r.dy) * cross;
}
if (a2.abs() < 1e-6) {
c.area = 0;
return;
}
c.area = a2.abs() / 2;
c.centroid = Offset(cx / (3 * a2), cy / (3 * a2));
}
/// Umriss von [id]: die Zelle mit weich gerundeten Ecken („aufgeblasen“).
Path outline(String id) {
final c = cells[id];
final path = Path();
if (c == null) return path;
final p = <Offset>[];
for (final v in c.polygon) {
if (p.isEmpty || (v - p.last).distance > 0.5) p.add(v);
}
if (p.length > 2 && (p.first - p.last).distance <= 0.5) p.removeLast();
if (p.length < 3) return path;
final rcMax = math.min(72.0, math.sqrt(c.area) * 0.34);
for (var i = 0; i < p.length; i++) {
final v = p[i];
final prev = p[(i - 1 + p.length) % p.length];
final next = p[(i + 1) % p.length];
final lp = (prev - v).distance, ln = (next - v).distance;
final r = math.min(rcMax, math.min(lp, ln) * 0.5);
final a = v + (prev - v) / lp * r;
final b = v + (next - v) / ln * r;
if (i == 0) {
path.moveTo(a.dx, a.dy);
} else {
path.lineTo(a.dx, a.dy);
}
// Kubische Ecke (etwas voller als ein Viertelkreis – wirkt prall).
final c1 = a + (v - a) * 0.62, c2 = b + (v - b) * 0.62;
path.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, b.dx, b.dy);
}
path.close();
return path;
}
/// Rechteck um die Zelle (für Inhalte wie Video).
Rect cellBounds(String id) {
final p = cells[id]?.polygon ?? const [];
if (p.isEmpty) return Rect.zero;
var l = p.first.dx, t = p.first.dy, r = l, b = t;
for (final v in p) {
l = math.min(l, v.dx);
t = math.min(t, v.dy);
r = math.max(r, v.dx);
b = math.max(b, v.dy);
}
return Rect.fromLTRB(l, t, r, b);
}
}
+245 -171
View File
@@ -1,8 +1,10 @@
part of '../voice_channel.dart'; part of '../voice_channel.dart';
/// Anrufansicht als aneinandergedrückte Bubbles (Physik: bubble_physics.dart). /// Anrufansicht als „aufgeblasene“ Formen (Layout: inflated_layout.dart,
/// Jede Person ist eine Blase mit Profilbild (oder Kamera-Bild), wer spricht, /// Bernd 2026-10-10): Jede Person füllt ihre Zelle bis an die Nachbarn und
/// wächst und leuchtet; Blasen weichen sich federnd aus. /// den Fensterrand, dargestellt als Kontur mit weichem Innenleuchten; in der
/// Mitte Profilbild und Name. Wer spricht, pumpt sich auf und leuchtet grün,
/// die anderen geben federnd nach. Kamera-Bild füllt die Form.
class _BubbleStage extends StatefulWidget { class _BubbleStage extends StatefulWidget {
final List<_ParticipantData> participants; final List<_ParticipantData> participants;
final Client? client; final Client? client;
@@ -23,7 +25,7 @@ class _BubbleStage extends StatefulWidget {
class _BubbleStageState extends State<_BubbleStage> class _BubbleStageState extends State<_BubbleStage>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
final _sim = BubbleSim(); final _layout = InflatedLayout();
// Letzter bekannter Stand je Person – auch für Gehende, die noch // Letzter bekannter Stand je Person – auch für Gehende, die noch
// wegschrumpfen. // wegschrumpfen.
final Map<String, _ParticipantData> _data = {}; final Map<String, _ParticipantData> _data = {};
@@ -50,8 +52,8 @@ class _BubbleStageState extends State<_BubbleStage>
: (elapsed - _last!).inMicroseconds / Duration.microsecondsPerSecond; : (elapsed - _last!).inMicroseconds / Duration.microsecondsPerSecond;
_last = elapsed; _last = elapsed;
_time += dt; _time += dt;
final moving = _sim.step(dt); final moving = _layout.step(dt);
_data.removeWhere((id, _) => !_sim.bodies.containsKey(id)); _data.removeWhere((id, _) => !_layout.cells.containsKey(id));
final speaking = widget.participants.any((p) => p.isSpeaking); final speaking = widget.participants.any((p) => p.isSpeaking);
setState(() {}); setState(() {});
// Ruhe = keine Animation (spart Akku); Sprechen hält das Leuchten an. // Ruhe = keine Animation (spart Akku); Sprechen hält das Leuchten an.
@@ -65,7 +67,7 @@ class _BubbleStageState extends State<_BubbleStage>
for (final p in widget.participants) { for (final p in widget.participants) {
_data[p.id] = p; _data[p.id] = p;
} }
_sim.sync( _layout.sync(
widget.participants.map((p) => p.id).toList(), widget.participants.map((p) => p.id).toList(),
widget.participants.where((p) => p.isSpeaking).map((p) => p.id).toSet(), widget.participants.where((p) => p.isSpeaking).map((p) => p.id).toSet(),
size, size,
@@ -81,155 +83,146 @@ class _BubbleStageState extends State<_BubbleStage>
final pt = widget.pt; final pt = widget.pt;
return LayoutBuilder(builder: (context, box) { return LayoutBuilder(builder: (context, box) {
final size = box.biggest; final size = box.biggest;
if (_needsSync || size != _sim.size) { if (_needsSync || size != _layout.size) {
_needsSync = false; _needsSync = false;
_sync(size); _sync(size);
} }
final glow = 0.5 + 0.5 * math.sin(_time * 6); final glow = 0.5 + 0.5 * math.sin(_time * 5);
final children = <Widget>[]; final shapes = <_CellShape>[];
for (final b in _sim.bodies.values) { for (final c in _layout.cells.values) {
final d = _data[b.id]; final d = _data[c.id];
if (d == null || b.r < 2) continue; if (d == null || c.area < 16) continue;
final rect = Rect.fromCircle(center: b.pos, radius: b.r); shapes.add(_CellShape(
children.add(Positioned.fromRect( data: d,
key: ValueKey('bubble:${b.id}'), path: _layout.outline(c.id),
rect: rect, bounds: _layout.cellBounds(c.id),
child: _Bubble( center: c.centroid,
data: d, opacity: c.share.clamp(0.0, 1.0),
path: _sim.clipPath(b.id).shift(-rect.topLeft),
radius: b.r,
client: widget.client,
pt: pt,
glow: glow,
onTap: () => widget.onSelect(d),
),
)); ));
} }
return DecoratedBox(
decoration: BoxDecoration( return ColoredBox(
gradient: RadialGradient( color: pt.bg1,
colors: [pt.bg2, pt.bg1], child: Stack(
radius: 0.9, clipBehavior: Clip.hardEdge,
), children: [
// Kamera-Bild füllt die Form.
for (final s in shapes)
if (s.data.hasVideo && s.data.coverVideoWidget != null)
Positioned.fill(
key: ValueKey('video:${s.data.id}'),
child: ClipPath(
clipper: _PathClipper(s.path),
child: Stack(children: [
Positioned.fromRect(
rect: s.bounds,
child: _MirrorWrapper(
isLocal: s.data.isLocal,
isScreenShare: s.data.isScreenShare,
child: s.data.coverVideoWidget!,
),
),
]),
),
),
// Konturen (alle in einem Zeichenvorgang).
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _ContourPainter(
shapes: shapes,
glow: glow,
speakingColor: pt.online,
),
),
),
),
// Profilbild + Name in der Mitte jeder Form.
for (final s in shapes)
Positioned.fromRect(
key: ValueKey('content:${s.data.id}'),
rect: Rect.fromCenter(
center: s.data.hasVideo
? Offset(s.center.dx, s.bounds.bottom - 28)
: s.center,
width: s.bounds.width * 0.86,
height: s.data.hasVideo ? 40 : s.bounds.height * 0.82,
),
child: IgnorePointer(
child: Opacity(
opacity: s.opacity,
child: _CellContent(
data: s.data,
client: widget.client,
pt: pt,
cellSize: s.bounds.size,
),
),
),
),
// Antippen: Form mit Kamera-Bild groß zeigen.
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTapUp: (details) {
for (final s in shapes) {
if (s.path.contains(details.localPosition)) {
widget.onSelect(s.data);
return;
}
}
},
),
),
],
), ),
child: Stack(clipBehavior: Clip.hardEdge, children: children),
); );
}); });
} }
} }
class _Bubble extends StatelessWidget { class _CellShape {
final _ParticipantData data; final _ParticipantData data;
final Path path; final Path path;
final double radius; final Rect bounds;
final Client? client; final Offset center;
final PyramidTheme pt; final double opacity;
final double glow; const _CellShape({
final VoidCallback onTap;
const _Bubble({
required this.data, required this.data,
required this.path, required this.path,
required this.radius, required this.bounds,
required this.center,
required this.opacity,
});
}
/// Profilbild (rund, mit Kontur) und Name – skaliert sich in jede Zellgröße.
class _CellContent extends StatelessWidget {
final _ParticipantData data;
final Client? client;
final PyramidTheme pt;
final Size cellSize;
const _CellContent({
required this.data,
required this.client, required this.client,
required this.pt, required this.pt,
required this.glow, required this.cellSize,
required this.onTap,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final showName = radius > 38; final avatar = (math.min(cellSize.width, cellSize.height) * 0.36)
return Semantics( .clamp(28.0, 132.0)
label: data.isMuted ? '${data.name}, stumm' : data.name, .toDouble();
button: data.hasVideo, final fontSize = (avatar * 0.15).clamp(11.0, 16.0).toDouble();
child: GestureDetector( final nameTag = Container(
onTap: onTap,
child: CustomPaint(
foregroundPainter: _BubbleRingPainter(
path: path,
speaking: data.isSpeaking,
glow: glow,
color: pt.online,
),
child: ClipPath(
clipper: _PathClipper(path),
child: Stack(
fit: StackFit.expand,
children: [
_face(),
if (data.hasVideo && data.coverVideoWidget != null)
_MirrorWrapper(
isLocal: data.isLocal,
isScreenShare: data.isScreenShare,
child: data.coverVideoWidget!,
),
const IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment(-0.45, -0.55),
radius: 0.95,
colors: [Color(0x2EFFFFFF), Color(0x00FFFFFF)],
),
),
),
),
if (showName)
Align(
alignment: const Alignment(0, 0.62),
child: _nameTag(),
),
],
),
),
),
),
);
}
/// Profilbild oder Farbverlauf mit Anfangsbuchstaben – fest in 256 px
/// gebaut und skaliert, damit das Bild beim Wachsen nicht neu lädt.
Widget _face() {
Widget initial(BuildContext _) => Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [data.avatarColor, data.avatarColor.withAlpha(150)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
alignment: const Alignment(0, -0.12),
child: Text(data.initial,
style: const TextStyle(
color: Colors.white,
fontSize: 104,
fontWeight: FontWeight.w700)),
);
final c = client;
final face = (c != null && data.avatarUrl != null)
? MxcAvatar(
mxcUri: data.avatarUrl,
client: c,
size: 256,
borderRadius: BorderRadius.zero,
placeholder: initial,
)
: Builder(builder: initial);
return FittedBox(
fit: BoxFit.cover,
child: SizedBox(width: 256, height: 256, child: face),
);
}
Widget _nameTag() {
final fontSize = (radius * 0.16).clamp(10.0, 15.0);
return Container(
constraints: BoxConstraints(maxWidth: radius * 1.5),
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: fontSize * 0.7, vertical: fontSize * 0.25), horizontal: fontSize * 0.7, vertical: fontSize * 0.25),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.45), color: data.hasVideo
? Colors.black.withValues(alpha: 0.5)
: Colors.transparent,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
), ),
child: Row( child: Row(
@@ -239,21 +232,91 @@ class _Bubble extends StatelessWidget {
Icon(Icons.mic_off_rounded, size: fontSize + 1, color: pt.danger), Icon(Icons.mic_off_rounded, size: fontSize + 1, color: pt.danger),
SizedBox(width: fontSize * 0.3), SizedBox(width: fontSize * 0.3),
], ],
Flexible( Text(
child: Text( data.name,
data.name, maxLines: 1,
maxLines: 1, overflow: TextOverflow.ellipsis,
overflow: TextOverflow.ellipsis, style: TextStyle(
style: TextStyle( color: data.hasVideo ? Colors.white : pt.fg,
color: Colors.white, fontSize: fontSize,
fontSize: fontSize, fontWeight: FontWeight.w600,
fontWeight: FontWeight.w600,
),
), ),
), ),
], ],
), ),
); );
if (data.hasVideo) {
return FittedBox(fit: BoxFit.scaleDown, child: nameTag);
}
return FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_OutlinedAvatar(
data: data,
client: client,
size: avatar,
ring: data.isSpeaking ? pt.online : data.avatarColor,
),
SizedBox(height: fontSize * 0.6),
nameTag,
],
),
);
}
}
/// Rundes Profilbild mit Kontur; ohne Bild nur der Anfangsbuchstabe in der
/// Konturfarbe (passt zum reinen Linien-Look).
class _OutlinedAvatar extends StatelessWidget {
final _ParticipantData data;
final Client? client;
final double size;
final Color ring;
const _OutlinedAvatar({
required this.data,
required this.client,
required this.size,
required this.ring,
});
@override
Widget build(BuildContext context) {
Widget letter(BuildContext _) => Container(
alignment: Alignment.center,
color: ring.withValues(alpha: 0.08),
// Gezeichnet in der 256er-Fläche, danach auf [size] skaliert.
child: Text(data.initial,
style: TextStyle(
color: ring, fontSize: 104, fontWeight: FontWeight.w600)),
);
final c = client;
final inner = (c != null && data.avatarUrl != null)
? MxcAvatar(
mxcUri: data.avatarUrl,
client: c,
size: 256,
borderRadius: BorderRadius.zero,
placeholder: letter,
)
: Builder(builder: letter);
return Container(
width: size,
height: size,
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: ring, width: 2),
),
child: ClipOval(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(width: 256, height: 256, child: inner),
),
),
);
} }
} }
@@ -268,60 +331,71 @@ class _PathClipper extends CustomClipper<Path> {
bool shouldReclip(_PathClipper old) => !identical(old.path, path); bool shouldReclip(_PathClipper old) => !identical(old.path, path);
} }
class _BubbleRingPainter extends CustomPainter { class _ContourPainter extends CustomPainter {
final Path path; final List<_CellShape> shapes;
final bool speaking;
final double glow; final double glow;
final Color color; final Color speakingColor;
_BubbleRingPainter({ _ContourPainter({
required this.path, required this.shapes,
required this.speaking,
required this.glow, required this.glow,
required this.color, required this.speakingColor,
}); });
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
if (speaking) { for (final s in shapes) {
final speaking = s.data.isSpeaking;
final color = speaking ? speakingColor : s.data.avatarColor;
final o = s.opacity;
// Weiches Leuchten innen an der Kontur – wirkt „prall“, bleibt aber
// ein Linien-Look.
canvas.save();
canvas.clipPath(s.path);
canvas.drawPath( canvas.drawPath(
path, s.path,
Paint() Paint()
..color = color.withValues(alpha: 0.30 + 0.30 * glow)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 7 ..strokeWidth = speaking ? 30 : 22
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6), ..color = color.withValues(
alpha: o * (speaking ? 0.20 + 0.12 * glow : 0.10))
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12),
); );
canvas.restore();
if (speaking) {
canvas.drawPath(
s.path,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 8
..color = color.withValues(alpha: o * (0.25 + 0.25 * glow))
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8),
);
}
canvas.drawPath( canvas.drawPath(
path, s.path,
Paint() Paint()
..color = color
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 3 ..strokeWidth = speaking ? 3 : 2
..strokeJoin = StrokeJoin.round, ..strokeJoin = StrokeJoin.round
); ..color = color.withValues(alpha: o * (speaking ? 1.0 : 0.85)),
} else {
canvas.drawPath(
path,
Paint()
..color = Colors.white.withValues(alpha: 0.10)
..style = PaintingStyle.stroke
..strokeWidth = 1.2,
); );
} }
} }
@override @override
bool shouldRepaint(_BubbleRingPainter old) => true; bool shouldRepaint(_ContourPainter old) => true;
} }
/// Vorführ-Ansicht der Bubbles mit erfundenen Teilnehmern – nur für /// Vorführ-Ansicht der Anruf-Formen mit erfundenen Teilnehmern – nur für
/// Screenshots/Tests (UI-Tour), damit man die Ansicht mit mehreren Personen /// Screenshots/Tests (UI-Tour), damit man die Ansicht mit mehreren Personen
/// sieht, ohne dass so viele Testkonten anrufen müssen. /// sieht, ohne dass so viele Testkonten anrufen müssen.
@visibleForTesting @visibleForTesting
class CallBubblesDemo extends StatelessWidget { class CallBubblesDemo extends StatelessWidget {
final int count; final int count;
const CallBubblesDemo({super.key, this.count = 5}); /// Wer gerade spricht (Index, -1 = niemand).
final int speaking;
const CallBubblesDemo({super.key, this.count = 5, this.speaking = 2});
static const _names = ['Du', 'Anna', 'Ben', 'Clara', 'David', 'Ela', 'Finn']; static const _names = ['Du', 'Anna', 'Ben', 'Clara', 'David', 'Ela', 'Finn'];
@@ -334,7 +408,7 @@ class CallBubblesDemo extends StatelessWidget {
id: 'demo$i', id: 'demo$i',
name: _names[i], name: _names[i],
isLocal: i == 0, isLocal: i == 0,
isSpeaking: i == 2, isSpeaking: i == speaking,
isMuted: i == 3, isMuted: i == 3,
avatarColor: VoiceChannelView._avatarColors[i % VoiceChannelView._avatarColors.length], avatarColor: VoiceChannelView._avatarColors[i % VoiceChannelView._avatarColors.length],
), ),
+1 -1
View File
@@ -9,7 +9,7 @@ import 'package:livekit_client/livekit_client.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/features/call_ui/bubble_physics.dart'; import 'package:pyramid/features/call_ui/inflated_layout.dart';
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/spring_press.dart' show motionDuration; import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
import 'package:pyramid/widgets/screen_share_picker.dart'; import 'package:pyramid/widgets/screen_share_picker.dart';
+1 -1
View File
@@ -1,7 +1,7 @@
name: pyramid name: pyramid
description: A flexible Matrix client with resizable panel layout. description: A flexible Matrix client with resizable panel layout.
publish_to: none publish_to: none
version: 0.1.42+43 version: 0.1.43+44
environment: environment:
sdk: ">=3.11.1 <4.0.0" sdk: ">=3.11.1 <4.0.0"
-94
View File
@@ -1,94 +0,0 @@
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));
});
}
+107
View File
@@ -0,0 +1,107 @@
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:pyramid/features/call_ui/inflated_layout.dart';
/// [seconds] lang simulieren (60 Schritte pro Sekunde).
bool _run(InflatedLayout l, double seconds) {
var moving = true;
for (var i = 0; i < (seconds * 60).round(); i++) {
moving = l.step(1 / 60);
}
return moving;
}
List<String> _ids(int n) => [for (var i = 0; i < n; i++) 'p$i'];
void main() {
const phone = Size(390, 620);
const desktop = Size(1200, 700);
test('eine Person füllt den ganzen Bereich bis an die Ränder', () {
final l = InflatedLayout()..sync(['solo'], {}, phone);
_run(l, 1);
final r = l.cellBounds('solo');
expect(r.left, closeTo(l.margin, 0.5));
expect(r.top, closeTo(l.margin, 0.5));
expect(r.right, closeTo(phone.width - l.margin, 0.5));
expect(r.bottom, closeTo(phone.height - l.margin, 0.5));
// Gerundete Ecken: der Umriss enthält die Ecke des Rechtecks nicht mehr.
final path = l.outline('solo');
expect(path.contains(r.topLeft + const Offset(2, 2)), isFalse);
expect(path.contains(r.center), isTrue);
});
test('vier Personen: gleich große Zellen, zusammen fast die ganze Fläche', () {
final l = InflatedLayout()..sync(_ids(4), {}, desktop);
final moving = _run(l, 6);
expect(moving, isFalse, reason: 'kommt nicht zur Ruhe');
final areas = l.cells.values.map((c) => c.area).toList();
final avg = areas.reduce((a, b) => a + b) / areas.length;
for (final a in areas) {
expect(a, closeTo(avg, avg * 0.08));
}
final total = areas.reduce((a, b) => a + b);
expect(total, greaterThan(l.bounds.width * l.bounds.height * 0.9));
});
test('wer spricht, bekommt deutlich mehr Fläche', () {
final l = InflatedLayout()..sync(_ids(3), {}, phone);
_run(l, 4);
l.sync(_ids(3), {'p1'}, phone);
_run(l, 5);
final speaker = l.cells['p1']!.area;
final other = l.cells['p0']!.area;
expect(speaker / other,
closeTo(InflatedLayout.speakingShare, InflatedLayout.speakingShare * 0.15));
});
test('je mehr Personen, desto kleiner die Zellen', () {
double avgArea(int n) {
final l = InflatedLayout()..sync(_ids(n), {}, phone);
_run(l, 5);
return l.cells.values.fold<double>(0, (s, c) => s + c.area) / n;
}
final a2 = avgArea(2), a5 = avgArea(5), a9 = avgArea(9);
expect(a5, lessThan(a2));
expect(a9, lessThan(a5));
});
test('Neue pumpen sich auf, Gehende verschwinden', () {
final l = InflatedLayout()..sync(_ids(2), {}, phone);
_run(l, 3);
l.sync(_ids(3), {}, phone);
expect(l.cells['p2']!.area, lessThan(l.cells['p0']!.area));
_run(l, 5);
expect(l.cells['p2']!.area, closeTo(l.cells['p0']!.area, l.cells['p0']!.area * 0.1));
l.sync(['p0', 'p2'], {}, phone);
_run(l, 3);
expect(l.cells.keys.toSet(), {'p0', 'p2'});
});
test('Zellen überlappen nicht und jeder Mittelpunkt liegt in seiner Zelle', () {
final l = InflatedLayout()..sync(_ids(7), {'p3'}, desktop);
_run(l, 6);
final paths = {for (final id in l.cells.keys) id: l.outline(id)};
for (final c in l.cells.values) {
expect(paths[c.id]!.contains(c.centroid), isTrue, reason: '${c.id} Schwerpunkt');
for (final other in l.cells.values) {
if (other.id == c.id) continue;
expect(paths[other.id]!.contains(c.centroid), isFalse,
reason: '${c.id} liegt in ${other.id}');
}
}
});
test('Drehen (hochkant → quer): zwei Personen nebeneinander', () {
final l = InflatedLayout()..sync(_ids(2), {}, phone);
_run(l, 3);
final a = l.cells['p0']!.centroid, b = l.cells['p1']!.centroid;
expect((a.dy - b.dy).abs(), greaterThan((a.dx - b.dx).abs()), reason: 'hochkant: übereinander');
l.sync(_ids(2), {}, desktop);
_run(l, 3);
final c = l.cells['p0']!.centroid, d = l.cells['p1']!.centroid;
expect((c.dx - d.dx).abs(), greaterThan((c.dy - d.dy).abs()), reason: 'quer: nebeneinander');
});
}