- 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]>
336 lines
10 KiB
Dart
336 lines
10 KiB
Dart
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;
|
|
final VoidCallback onHangUp;
|
|
final VoidCallback onMinimize;
|
|
final VoidCallback onToggleMic;
|
|
final VoidCallback onToggleDeafen;
|
|
final VoidCallback onToggleCam;
|
|
final VoidCallback onToggleScreen;
|
|
final bool isMuted;
|
|
final bool isDeafened;
|
|
final bool isCameraOff;
|
|
final bool isScreenSharing;
|
|
final Function(Offset)? onQualityMenu;
|
|
final PyramidTheme pt;
|
|
final bool isSpeakerOn;
|
|
final VoidCallback? onToggleSpeaker;
|
|
|
|
const _UnifiedCallView({
|
|
required this.participants,
|
|
required this.client,
|
|
required this.startedAt,
|
|
required this.isLiveKit,
|
|
required this.displayName,
|
|
required this.isConnecting,
|
|
required this.onHangUp,
|
|
required this.onMinimize,
|
|
required this.onToggleMic,
|
|
required this.onToggleDeafen,
|
|
required this.onToggleCam,
|
|
required this.onToggleScreen,
|
|
required this.isMuted,
|
|
required this.isDeafened,
|
|
required this.isCameraOff,
|
|
required this.isScreenSharing,
|
|
this.onQualityMenu,
|
|
required this.pt,
|
|
this.isSpeakerOn = true,
|
|
this.onToggleSpeaker,
|
|
});
|
|
|
|
@override
|
|
State<_UnifiedCallView> createState() => _UnifiedCallViewState();
|
|
}
|
|
|
|
class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
|
_CallLayout _mode = _CallLayout.bubbles;
|
|
String? _heroId;
|
|
bool _stripCollapsed = false;
|
|
Set<String> _knownScreens = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_followScreenShares();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(_UnifiedCallView oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
_followScreenShares();
|
|
}
|
|
|
|
/// 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) {
|
|
int rank(_ParticipantData p) {
|
|
if (p.isScreenShare) return 0;
|
|
if (p.hasVideo) return 1;
|
|
if (p.isSpeaking) return 2;
|
|
return 3;
|
|
}
|
|
return [...all]..sort((a, b) => rank(a).compareTo(rank(b)));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final pt = widget.pt;
|
|
final all = _sorted(widget.participants);
|
|
final hero = _mode == _CallLayout.hero
|
|
? widget.participants.firstWhereOrNull((p) => p.id == _heroId)
|
|
: null;
|
|
|
|
if (widget.isConnecting) {
|
|
return Scaffold(
|
|
backgroundColor: pt.bg1,
|
|
body: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const PyramidLoader(size: 80),
|
|
const SizedBox(height: 20),
|
|
Text('Verbinde…',
|
|
style: TextStyle(color: pt.fgMuted, fontSize: 14)),
|
|
const SizedBox(height: 16),
|
|
TextButton.icon(
|
|
style: TextButton.styleFrom(foregroundColor: pt.danger),
|
|
onPressed: widget.onHangUp,
|
|
icon: const Icon(Icons.call_end_rounded, size: 16),
|
|
label: const Text('Abbrechen'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Scaffold(
|
|
backgroundColor: pt.bg1,
|
|
body: Column(
|
|
children: [
|
|
_CallHeader(
|
|
displayName: widget.displayName,
|
|
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: 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) => p.hasVideo
|
|
? setState(() => _heroId = p.id)
|
|
: null,
|
|
onQualityMenu: widget.onQualityMenu,
|
|
pt: pt,
|
|
),
|
|
),
|
|
),
|
|
_CallControls(
|
|
isMuted: widget.isMuted,
|
|
isDeafened: widget.isDeafened,
|
|
isCameraOff: widget.isCameraOff,
|
|
isScreenSharing: widget.isScreenSharing,
|
|
onToggleMic: widget.onToggleMic,
|
|
onToggleDeafen: widget.onToggleDeafen,
|
|
onToggleCam: widget.onToggleCam,
|
|
onToggleScreen: widget.onToggleScreen,
|
|
onHangUp: widget.onHangUp,
|
|
pt: pt,
|
|
isSpeakerOn: widget.isSpeakerOn,
|
|
onToggleSpeaker: widget.onToggleSpeaker,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CallHeader extends StatelessWidget {
|
|
final String displayName;
|
|
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.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: 56,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
|
|
child: Row(
|
|
children: [
|
|
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(toBubbles ? Icons.bubble_chart_rounded : Icons.grid_view_rounded, color: pt.fg),
|
|
tooltip: toBubbles ? 'Bubble-Ansicht' : 'Kachelansicht',
|
|
onPressed: onToggleView,
|
|
),
|
|
const SizedBox(width: 4),
|
|
PyrIconBtn(
|
|
icon: Icon(Icons.close_fullscreen_rounded, color: pt.fg),
|
|
tooltip: 'Minimieren',
|
|
onPressed: onMinimize,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(999), color: pt.online.withAlpha(30)),
|
|
child: Row(children: [
|
|
Container(width: 6, height: 6, decoration: BoxDecoration(color: pt.online, shape: BoxShape.circle)),
|
|
const SizedBox(width: 4),
|
|
Text('Live', style: TextStyle(color: pt.online, fontSize: 11, fontWeight: FontWeight.w600)),
|
|
]),
|
|
);
|
|
}
|
|
}
|