Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1119c83af6 | ||
|
|
ab84701e73 | ||
|
|
d4473c7d64 |
+75
@@ -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
@@ -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');
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
|
||||||
key: ValueKey('bubble:${b.id}'),
|
|
||||||
rect: rect,
|
|
||||||
child: _Bubble(
|
|
||||||
data: d,
|
data: d,
|
||||||
path: _sim.clipPath(b.id).shift(-rect.topLeft),
|
path: _layout.outline(c.id),
|
||||||
radius: b.r,
|
bounds: _layout.cellBounds(c.id),
|
||||||
client: widget.client,
|
center: c.centroid,
|
||||||
pt: pt,
|
opacity: c.share.clamp(0.0, 1.0),
|
||||||
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!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Stack(clipBehavior: Clip.hardEdge, children: children),
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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: Colors.white,
|
color: data.hasVideo ? Colors.white : pt.fg,
|
||||||
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) {
|
||||||
|
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(
|
||||||
|
s.path,
|
||||||
|
Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = speaking ? 30 : 22
|
||||||
|
..color = color.withValues(
|
||||||
|
alpha: o * (speaking ? 0.20 + 0.12 * glow : 0.10))
|
||||||
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12),
|
||||||
|
);
|
||||||
|
canvas.restore();
|
||||||
if (speaking) {
|
if (speaking) {
|
||||||
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 = 8
|
||||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6),
|
..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],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user