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
+85 -6
View File
@@ -30,6 +30,15 @@ class _ChatHeader extends ConsumerWidget {
final client = ref.watch(matrixClientProvider).valueOrNull;
final size = MediaQuery.sizeOf(context);
final isMobile = size.width < 900 || size.height < 500;
// Gruppenchat (kein Server-Kanal, kein Sprachkanal): Kopf mit Avatar +
// Mitgliederzahl und Gruppenanruf (UI-Audit S3, Bernd 2026-10-09).
final isGroup = room != null &&
isGroupChatRoom(room!) &&
!ref.watch(spaceChannelIdsProvider).contains(room!.id);
final groupCallRunning = isGroup &&
otherCallParticipants(
ref.watch(voiceParticipantsProvider(room!.id)), room!)
.isNotEmpty;
return Container(
height: 52,
@@ -53,6 +62,8 @@ class _ChatHeader extends ConsumerWidget {
// Room icon / avatar
if (isDm && client != null) ...[
_DmHeaderAvatar(room: room, name: name, client: client),
] else if (isGroup && client != null) ...[
_GroupHeaderAvatar(room: room!, client: client, pt: pt),
] else if (!isDm) ...[
Icon(
isEncrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
@@ -85,6 +96,28 @@ class _ChatHeader extends ConsumerWidget {
],
),
)
else if (isGroup)
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
color: pt.fg,
fontSize: 15,
fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
Text(
_groupSubtitle(room!),
style: TextStyle(color: pt.fgDim, fontSize: 12),
overflow: TextOverflow.ellipsis,
),
],
),
)
else
Text(
name,
@@ -92,7 +125,9 @@ class _ChatHeader extends ConsumerWidget {
color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(width: 12),
if (!isDm && topic.isNotEmpty) ...[
if (isGroup)
const SizedBox.shrink()
else if (!isDm && topic.isNotEmpty) ...[
Container(width: 1, height: 18, color: pt.border),
const SizedBox(width: 12),
Expanded(
@@ -107,18 +142,25 @@ class _ChatHeader extends ConsumerWidget {
// Right actions
Row(
children: [
if (isDm) ...[
if (isDm || isGroup) ...[
PyrIconBtn(
size: 28,
icon: const Icon(Icons.phone_rounded),
tooltip: 'Anrufen',
onPressed: () => _startCall(ref, room, cam: false),
icon: Icon(Icons.phone_rounded,
color: groupCallRunning ? pt.online : null),
tooltip: groupCallRunning
? 'Gruppenanruf beitreten'
: (isGroup ? 'Gruppenanruf' : 'Anrufen'),
onPressed: () => isGroup
? startOrJoinGroupCall(context, ref, room!)
: _startCall(ref, room, cam: false),
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.videocam_rounded),
tooltip: 'Videoanruf',
onPressed: () => _startCall(ref, room, cam: true),
onPressed: () => isGroup
? startOrJoinGroupCall(context, ref, room!, video: true)
: _startCall(ref, room, cam: true),
),
if (size.width > 700)
Container(
@@ -635,6 +677,43 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
}
}
/// Untertitel im Gruppen-Kopf: Mitgliederzahl, dahinter das Thema.
String _groupSubtitle(Room room) {
final count = room.summary.mJoinedMemberCount ?? room.getParticipants().length;
final members = count == 1 ? '1 Mitglied' : '$count Mitglieder';
final topic = room.topic.trim();
return topic.isEmpty ? members : '$members · $topic';
}
/// Runder Gruppen-Avatar (Raumbild oder Gruppen-Symbol).
class _GroupHeaderAvatar extends StatelessWidget {
final Room room;
final Client client;
final PyramidTheme pt;
const _GroupHeaderAvatar(
{required this.room, required this.client, required this.pt});
@override
Widget build(BuildContext context) {
const size = 32.0;
Widget fallback(BuildContext _) => Container(
width: size,
height: size,
decoration:
BoxDecoration(color: pt.accentSoft, shape: BoxShape.circle),
child: Icon(Icons.groups_rounded, size: 18, color: pt.accent),
);
if (room.avatar == null) return Builder(builder: fallback);
return MxcAvatar(
mxcUri: room.avatar,
client: client,
size: size,
borderRadius: BorderRadius.circular(size / 2),
placeholder: fallback,
);
}
}
class _DmHeaderAvatar extends StatelessWidget {
final Room? room;
final String name;
@@ -521,6 +521,8 @@ class _ChatViewState extends ConsumerState<ChatView> {
),
const _ConnectionBanner(),
const _ActiveCallBar(),
if (room != null && !isInvite)
GroupCallBanner(room: room),
Expanded(
child: isInvite
? _InvitePanel(room: room!, pt: pt)