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:
Bernd Steckmeister
2026-10-10 14:54:45 +02:00
co-authored by Claude Opus 5.5
parent d4473c7d64
commit ab84701e73
8 changed files with 809 additions and 468 deletions
+245 -171
View File
@@ -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],
),