feat(ui): gemeinsame Chatliste, Gruppenanrufe mit Beitreten-Karte, Bubble-Anrufansicht

- Chats: Direktnachrichten + Gruppen ohne Server zusammen, Filter-Chips
  (Alle/Ungelesen/Personen/Gruppen), Leerzustaende mit Handlung; virtuelles
  'Raeume'-Symbol entfaellt, Server-Kanaele nur noch im Server
- Gruppen: runder Gruppen-Avatar, Kopf mit Mitgliederzahl, Anruf-/Video-Knopf
- Gruppenanruf: LiveKit + Anwesenheit, Start-Nachricht (Push), Karte
  'Gruppenanruf laeuft · Beitreten', Hinweis-Karte im Verlauf
- Anrufansicht: aneinandergedrueckte Bubbles (Federphysik, Sprecher waechst),
  Kacheln umschaltbar, Bildschirm automatisch gross, Gespraechsdauer,
  Matrix-Namen/-Avatare statt LiveKit-IDs
- Tests: test/bubble_physics_test.dart

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-09 11:58:31 +02:00
co-authored by Claude Opus 5.5
parent 87b472a373
commit de24053167
24 changed files with 1550 additions and 314 deletions
+145 -37
View File
@@ -1,7 +1,13 @@
part of '../voice_channel.dart';
/// Darstellung der Teilnehmer: Bubbles (Standard), Kacheln, oder ein großes
/// Bild (Bildschirm/Kamera) mit Leiste.
enum _CallLayout { bubbles, grid, hero }
class _UnifiedCallView extends StatefulWidget {
final List<_ParticipantData> participants;
final Client? client;
final DateTime startedAt;
final bool isLiveKit;
final String displayName;
final bool isConnecting;
@@ -22,6 +28,8 @@ class _UnifiedCallView extends StatefulWidget {
const _UnifiedCallView({
required this.participants,
required this.client,
required this.startedAt,
required this.isLiveKit,
required this.displayName,
required this.isConnecting,
@@ -46,31 +54,49 @@ class _UnifiedCallView extends StatefulWidget {
}
class _UnifiedCallViewState extends State<_UnifiedCallView> {
_CallLayout _mode = _CallLayout.bubbles;
String? _heroId;
bool _isGridView = false;
bool _stripCollapsed = false;
Set<String> _knownScreens = {};
@override
void initState() {
super.initState();
_autoSelectHero();
_followScreenShares();
}
@override
void didUpdateWidget(_UnifiedCallView oldWidget) {
super.didUpdateWidget(oldWidget);
_autoSelectHero();
_followScreenShares();
}
void _autoSelectHero() {
if (_isGridView) return;
final screenshare = widget.participants.firstWhereOrNull((p) => p.isScreenShare);
if (screenshare != null && (_heroId == null || !widget.participants.any((p) => p.id == _heroId && p.hasVideo))) {
_heroId = screenshare.id;
} else if (_heroId == null && widget.participants.isNotEmpty) {
final firstVideo = widget.participants.firstWhereOrNull((p) => p.hasVideo);
if (firstVideo != null) _heroId = firstVideo.id;
/// Neue Bildschirmübertragung → groß zeigen. Verschwindet das große Bild
/// (Person weg, Kamera aus), zurück zu den Bubbles.
void _followScreenShares() {
final screens = widget.participants
.where((p) => p.isScreenShare)
.map((p) => p.id)
.toSet();
final fresh = screens.difference(_knownScreens);
if (fresh.isNotEmpty) {
_mode = _CallLayout.hero;
_heroId = fresh.first;
}
_knownScreens = screens;
if (_mode == _CallLayout.hero &&
!widget.participants.any((p) => p.id == _heroId && p.hasVideo)) {
_mode = _CallLayout.bubbles;
_heroId = null;
}
}
void _showLarge(_ParticipantData p) {
if (!p.hasVideo) return;
setState(() {
_mode = _CallLayout.hero;
_heroId = p.id;
});
}
List<_ParticipantData> _sorted(List<_ParticipantData> all) {
@@ -87,15 +113,10 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
Widget build(BuildContext context) {
final pt = widget.pt;
final all = _sorted(widget.participants);
// Ohne gewählten Hero nicht nach „spricht gerade“ wählen – sonst springt
// das große Bild bei jedem Sprechwechsel hin und her.
final stable = [...widget.participants]..sort((a, b) {
int r(_ParticipantData p) => p.isScreenShare ? 0 : (p.hasVideo ? 1 : 2);
return r(a).compareTo(r(b));
});
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ??
(stable.isNotEmpty ? stable.first : null);
final hero = _mode == _CallLayout.hero
? widget.participants.firstWhereOrNull((p) => p.id == _heroId)
: null;
if (widget.isConnecting) {
return Scaffold(
backgroundColor: pt.bg1,
@@ -126,23 +147,44 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
children: [
_CallHeader(
displayName: widget.displayName,
isGridView: _isGridView,
onToggleView: () => setState(() => _isGridView = !_isGridView),
startedAt: widget.startedAt,
count: widget.participants.length,
layout: _mode,
onToggleView: () => setState(() {
_mode = _mode == _CallLayout.bubbles
? _CallLayout.grid
: _CallLayout.bubbles;
_heroId = null;
}),
onMinimize: widget.onMinimize,
pt: pt,
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _isGridView || hero == null
? _ParticipantGrid(participants: all, pt: pt, onSelect: (p) => setState(() { _heroId = p.id; _isGridView = false; }))
duration: motionDuration(context, 300),
child: hero == null
? (_mode == _CallLayout.grid
? _ParticipantGrid(
key: const ValueKey('grid'),
participants: all,
pt: pt,
onSelect: _showLarge)
: _BubbleStage(
key: const ValueKey('bubbles'),
participants: widget.participants,
client: widget.client,
pt: pt,
onSelect: _showLarge))
: _HeroLayout(
key: const ValueKey('hero'),
hero: hero,
others: all.where((p) => p.id != hero.id).toList(),
stripCollapsed: _stripCollapsed,
onToggleStrip: () => setState(() => _stripCollapsed = !_stripCollapsed),
onSetStripCollapsed: (v) => setState(() => _stripCollapsed = v),
onSelectOther: (p) => setState(() => _heroId = p.id),
onSelectOther: (p) => p.hasVideo
? setState(() => _heroId = p.id)
: null,
onQualityMenu: widget.onQualityMenu,
pt: pt,
),
@@ -170,30 +212,55 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
class _CallHeader extends StatelessWidget {
final String displayName;
final bool isGridView;
final DateTime startedAt;
final int count;
final _CallLayout layout;
final VoidCallback onToggleView;
final VoidCallback onMinimize;
final PyramidTheme pt;
const _CallHeader({required this.displayName, required this.isGridView, required this.onToggleView, required this.onMinimize, required this.pt});
const _CallHeader({
required this.displayName,
required this.startedAt,
required this.count,
required this.layout,
required this.onToggleView,
required this.onMinimize,
required this.pt,
});
@override
Widget build(BuildContext context) {
final toBubbles = layout != _CallLayout.bubbles;
return Container(
height: 52,
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
child: Row(
children: [
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
const SizedBox(width: 8),
Text(displayName, style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
_LiveBadge(pt: pt),
const Spacer(),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Row(children: [
_LiveBadge(pt: pt),
const SizedBox(width: 8),
_CallDuration(startedAt: startedAt, pt: pt),
Text(count == 1 ? ' · nur du' : ' · $count Personen',
style: TextStyle(color: pt.fgMuted, fontSize: 12)),
]),
],
),
),
PyrIconBtn(
icon: Icon(isGridView ? Icons.grid_view_rounded : Icons.featured_video_rounded, color: isGridView ? pt.accent : pt.fg),
tooltip: isGridView ? 'Großansicht' : 'Kachelansicht',
icon: Icon(toBubbles ? Icons.bubble_chart_rounded : Icons.grid_view_rounded, color: pt.fg),
tooltip: toBubbles ? 'Bubble-Ansicht' : 'Kachelansicht',
onPressed: onToggleView,
),
const SizedBox(width: 4),
@@ -208,6 +275,47 @@ class _CallHeader extends StatelessWidget {
}
}
/// Gesprächsdauer „m:ss“ bzw. „h:mm:ss“, tickt jede Sekunde.
class _CallDuration extends StatefulWidget {
final DateTime startedAt;
final PyramidTheme pt;
const _CallDuration({required this.startedAt, required this.pt});
@override
State<_CallDuration> createState() => _CallDurationState();
}
class _CallDurationState extends State<_CallDuration> {
late final Timer _timer =
Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
@override
void initState() {
super.initState();
_timer; // starten
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final d = DateTime.now().difference(widget.startedAt);
String two(int n) => n.toString().padLeft(2, '0');
final text = d.inHours > 0
? '${d.inHours}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}'
: '${d.inMinutes}:${two(d.inSeconds % 60)}';
return Text(text,
style: TextStyle(
color: widget.pt.fgMuted,
fontSize: 12,
fontFeatures: const [FontFeature.tabularFigures()]));
}
}
class _LiveBadge extends StatelessWidget {
final PyramidTheme pt;
const _LiveBadge({required this.pt});