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:
co-authored by
Claude Opus 5.5
parent
87b472a373
commit
de24053167
@@ -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});
|
||||
|
||||
Reference in New Issue
Block a user