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
@@ -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],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user