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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d4473c7d64
commit
ab84701e73
@@ -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';
|
||||
|
||||
/// Anrufansicht als aneinandergedrückte Bubbles (Physik: bubble_physics.dart).
|
||||
/// Jede Person ist eine Blase mit Profilbild (oder Kamera-Bild), wer spricht,
|
||||
/// wächst und leuchtet; Blasen weichen sich federnd aus.
|
||||
/// Anrufansicht als „aufgeblasene“ Formen (Layout: inflated_layout.dart,
|
||||
/// Bernd 2026-10-10): Jede Person füllt ihre Zelle bis an die Nachbarn und
|
||||
/// 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 {
|
||||
final List<_ParticipantData> participants;
|
||||
final Client? client;
|
||||
@@ -23,7 +25,7 @@ class _BubbleStage extends StatefulWidget {
|
||||
|
||||
class _BubbleStageState extends State<_BubbleStage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _sim = BubbleSim();
|
||||
final _layout = InflatedLayout();
|
||||
// Letzter bekannter Stand je Person – auch für Gehende, die noch
|
||||
// wegschrumpfen.
|
||||
final Map<String, _ParticipantData> _data = {};
|
||||
@@ -50,8 +52,8 @@ class _BubbleStageState extends State<_BubbleStage>
|
||||
: (elapsed - _last!).inMicroseconds / Duration.microsecondsPerSecond;
|
||||
_last = elapsed;
|
||||
_time += dt;
|
||||
final moving = _sim.step(dt);
|
||||
_data.removeWhere((id, _) => !_sim.bodies.containsKey(id));
|
||||
final moving = _layout.step(dt);
|
||||
_data.removeWhere((id, _) => !_layout.cells.containsKey(id));
|
||||
final speaking = widget.participants.any((p) => p.isSpeaking);
|
||||
setState(() {});
|
||||
// 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) {
|
||||
_data[p.id] = p;
|
||||
}
|
||||
_sim.sync(
|
||||
_layout.sync(
|
||||
widget.participants.map((p) => p.id).toList(),
|
||||
widget.participants.where((p) => p.isSpeaking).map((p) => p.id).toSet(),
|
||||
size,
|
||||
@@ -81,155 +83,146 @@ class _BubbleStageState extends State<_BubbleStage>
|
||||
final pt = widget.pt;
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final size = box.biggest;
|
||||
if (_needsSync || size != _sim.size) {
|
||||
if (_needsSync || size != _layout.size) {
|
||||
_needsSync = false;
|
||||
_sync(size);
|
||||
}
|
||||
final glow = 0.5 + 0.5 * math.sin(_time * 6);
|
||||
final children = <Widget>[];
|
||||
for (final b in _sim.bodies.values) {
|
||||
final d = _data[b.id];
|
||||
if (d == null || b.r < 2) continue;
|
||||
final rect = Rect.fromCircle(center: b.pos, radius: b.r);
|
||||
children.add(Positioned.fromRect(
|
||||
key: ValueKey('bubble:${b.id}'),
|
||||
rect: rect,
|
||||
child: _Bubble(
|
||||
data: d,
|
||||
path: _sim.clipPath(b.id).shift(-rect.topLeft),
|
||||
radius: b.r,
|
||||
client: widget.client,
|
||||
pt: pt,
|
||||
glow: glow,
|
||||
onTap: () => widget.onSelect(d),
|
||||
),
|
||||
final glow = 0.5 + 0.5 * math.sin(_time * 5);
|
||||
final shapes = <_CellShape>[];
|
||||
for (final c in _layout.cells.values) {
|
||||
final d = _data[c.id];
|
||||
if (d == null || c.area < 16) continue;
|
||||
shapes.add(_CellShape(
|
||||
data: d,
|
||||
path: _layout.outline(c.id),
|
||||
bounds: _layout.cellBounds(c.id),
|
||||
center: c.centroid,
|
||||
opacity: c.share.clamp(0.0, 1.0),
|
||||
));
|
||||
}
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
colors: [pt.bg2, pt.bg1],
|
||||
radius: 0.9,
|
||||
),
|
||||
|
||||
return ColoredBox(
|
||||
color: pt.bg1,
|
||||
child: Stack(
|
||||
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 Path path;
|
||||
final double radius;
|
||||
final Client? client;
|
||||
final PyramidTheme pt;
|
||||
final double glow;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _Bubble({
|
||||
final Rect bounds;
|
||||
final Offset center;
|
||||
final double opacity;
|
||||
const _CellShape({
|
||||
required this.data,
|
||||
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.pt,
|
||||
required this.glow,
|
||||
required this.onTap,
|
||||
required this.cellSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showName = radius > 38;
|
||||
return Semantics(
|
||||
label: data.isMuted ? '${data.name}, stumm' : data.name,
|
||||
button: data.hasVideo,
|
||||
child: GestureDetector(
|
||||
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),
|
||||
final avatar = (math.min(cellSize.width, cellSize.height) * 0.36)
|
||||
.clamp(28.0, 132.0)
|
||||
.toDouble();
|
||||
final fontSize = (avatar * 0.15).clamp(11.0, 16.0).toDouble();
|
||||
final nameTag = Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: fontSize * 0.7, vertical: fontSize * 0.25),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.45),
|
||||
color: data.hasVideo
|
||||
? Colors.black.withValues(alpha: 0.5)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
@@ -239,21 +232,91 @@ class _Bubble extends StatelessWidget {
|
||||
Icon(Icons.mic_off_rounded, size: fontSize + 1, color: pt.danger),
|
||||
SizedBox(width: fontSize * 0.3),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
data.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
Text(
|
||||
data.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: data.hasVideo ? Colors.white : pt.fg,
|
||||
fontSize: fontSize,
|
||||
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);
|
||||
}
|
||||
|
||||
class _BubbleRingPainter extends CustomPainter {
|
||||
final Path path;
|
||||
final bool speaking;
|
||||
class _ContourPainter extends CustomPainter {
|
||||
final List<_CellShape> shapes;
|
||||
final double glow;
|
||||
final Color color;
|
||||
final Color speakingColor;
|
||||
|
||||
_BubbleRingPainter({
|
||||
required this.path,
|
||||
required this.speaking,
|
||||
_ContourPainter({
|
||||
required this.shapes,
|
||||
required this.glow,
|
||||
required this.color,
|
||||
required this.speakingColor,
|
||||
});
|
||||
|
||||
@override
|
||||
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(
|
||||
path,
|
||||
s.path,
|
||||
Paint()
|
||||
..color = color.withValues(alpha: 0.30 + 0.30 * glow)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 7
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6),
|
||||
..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) {
|
||||
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(
|
||||
path,
|
||||
s.path,
|
||||
Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3
|
||||
..strokeJoin = StrokeJoin.round,
|
||||
);
|
||||
} else {
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.10)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.2,
|
||||
..strokeWidth = speaking ? 3 : 2
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..color = color.withValues(alpha: o * (speaking ? 1.0 : 0.85)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
/// sieht, ohne dass so viele Testkonten anrufen müssen.
|
||||
@visibleForTesting
|
||||
class CallBubblesDemo extends StatelessWidget {
|
||||
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'];
|
||||
|
||||
@@ -334,7 +408,7 @@ class CallBubblesDemo extends StatelessWidget {
|
||||
id: 'demo$i',
|
||||
name: _names[i],
|
||||
isLocal: i == 0,
|
||||
isSpeaking: i == 2,
|
||||
isSpeaking: i == speaking,
|
||||
isMuted: i == 3,
|
||||
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:pyramid/core/app_state.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/spring_press.dart' show motionDuration;
|
||||
import 'package:pyramid/widgets/screen_share_picker.dart';
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: pyramid
|
||||
description: A flexible Matrix client with resizable panel layout.
|
||||
publish_to: none
|
||||
version: 0.1.42+43
|
||||
version: 0.1.43+44
|
||||
environment:
|
||||
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