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
+362
View File
@@ -0,0 +1,362 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/spring_press.dart';
/// Gruppenanrufe in Gruppenchats (Bernd 2026-10-09: „Anruf + Beitreten-Karte“).
///
/// - Technik wie bei Sprachkanälen: LiveKit-Raum = Matrix-Raum-ID, Anwesenheit
/// über `io.pyramid.voice.presence`-State-Events (Herzschlag alle 30 s).
/// - Wer einen Anruf eröffnet (sonst niemand drin), schickt die Nachricht
/// „📞 Gruppenanruf gestartet“ mit [groupCallContentKey]. Sie löst bei den
/// Mitgliedern eine normale Push-Benachrichtigung aus (eine m.notice würde
/// von den Standard-Push-Regeln stumm geschaltet) und erscheint in Pyramid
/// als Anruf-Karte; andere Apps zeigen den Text.
/// - Damit alle Mitglieder ihre Anwesenheit schreiben dürfen, wird die nötige
/// Berechtigung für dieses eine State-Event auf „alle“ gesenkt – nur wer
/// Rechte ändern darf (neue Gruppen bekommen das schon beim Erstellen).
const groupCallContentKey = 'io.pyramid.group_call';
/// Erlaubt allen Mitgliedern von [roomId], ihre Anruf-Anwesenheit zu
/// schreiben (sonst sieht man im Anruf nur Admins). Ändert nur diesen einen
/// Eintrag der Berechtigungen.
Future<void> allowVoicePresenceForAll(Client client, String roomId) async {
final plRaw =
await client.getRoomStateWithKey(roomId, EventTypes.RoomPowerLevels, '');
final pl = Map<String, dynamic>.from(plRaw);
final events = Map<String, dynamic>.from(pl['events'] as Map? ?? {});
final usersDefault = pl['users_default'] is int ? pl['users_default'] as int : 0;
if (events[kVoicePresenceType] == usersDefault) return;
events[kVoicePresenceType] = usersDefault;
pl['events'] = events;
await client.setRoomStateWithKey(roomId, EventTypes.RoomPowerLevels, '', pl);
}
/// Wie [allowVoicePresenceForAll], aber nur wenn nötig UND erlaubt.
Future<void> ensureVoicePresenceWritable(Room room) async {
final pl = room.getState(EventTypes.RoomPowerLevels)?.content;
if (pl == null) return; // keine Rechte-Tabelle: jeder darf
final usersDefault = pl['users_default'] is int ? pl['users_default'] as int : 0;
if (room.powerForChangingStateEvent(kVoicePresenceType) <= usersDefault) {
return;
}
if (!room.canChangePowerLevel) return;
await allowVoicePresenceForAll(room.client, room.id);
}
/// Bin ich gerade (oder gleich) im Gruppenanruf von [roomId]?
bool isInGroupCall(VoiceChannelService call, String roomId) =>
(call.isActive || call.isConnecting) && call.currentRoomId == roomId;
/// Teilnehmer des Anrufs in [room] ohne mich selbst.
List<Map<String, dynamic>> otherCallParticipants(
List<Map<String, dynamic>> all, Room room) =>
all.where((p) => p['userId'] != room.client.userID).toList();
/// Anruf-Knopf im Gruppenchat und „Beitreten“: läuft schon ein Anruf, tritt
/// man bei; sonst startet man ihn und benachrichtigt die Mitglieder.
Future<void> startOrJoinGroupCall(
BuildContext context,
WidgetRef ref,
Room room, {
bool video = false,
}) async {
final call = ref.read(voiceChannelProvider);
if (isInGroupCall(call, room.id)) {
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
return;
}
final voip = ref.read(callSignalingProvider).currentCall;
if (voip != null && voip.state != CallState.kEnded) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(const SnackBar(
content: Text('Beende zuerst den laufenden Anruf.')));
return;
}
final othersBefore = otherCallParticipants(
ref.read(voiceParticipantsProvider(room.id)), room)
.length;
if (call.isActive || call.isConnecting) await call.hangUp();
ref.read(activeVoiceRoomIdProvider.notifier).state = room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
try {
await ensureVoicePresenceWritable(room);
} catch (e) {
debugPrint('[GroupCall] Berechtigung für Anwesenheit: $e');
}
await call.startCall(
roomName: room.id,
roomDisplayName: room.getLocalizedDisplayname(),
identity: room.client.userID ?? '',
audioOnly: !video,
matrixClient: room.client,
matrixRoomId: room.id,
);
if (othersBefore == 0 &&
call.isActive &&
call.error == null &&
call.currentRoomId == room.id) {
try {
await room.sendEvent({
'msgtype': MessageTypes.Text,
'body': video ? '📹 Videoanruf gestartet' : '📞 Gruppenanruf gestartet',
groupCallContentKey: {'action': 'start', 'video': video},
});
} catch (e) {
debugPrint('[GroupCall] Benachrichtigung: $e');
}
}
}
/// Avatare der Teilnehmer, leicht überlappend (max. [max]).
class CallAvatarStack extends StatelessWidget {
final List<Map<String, dynamic>> participants;
final Client client;
final double size;
final int max;
final Color ringColor;
const CallAvatarStack({
super.key,
required this.participants,
required this.client,
required this.ringColor,
this.size = 30,
this.max = 3,
});
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final shown = participants.take(max).toList();
final step = size * 0.62;
return SizedBox(
width: shown.isEmpty ? 0 : size + step * (shown.length - 1),
height: size,
child: Stack(
children: [
for (var i = shown.length - 1; i >= 0; i--)
Positioned(
left: step * i,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: ringColor, width: 2),
),
child: ClipOval(child: _avatar(shown[i], pt)),
),
),
],
),
);
}
Widget _avatar(Map<String, dynamic> p, PyramidTheme pt) {
final name = (p['displayName'] as String? ?? '?').replaceFirst('@', '');
final initial = name.isEmpty ? '?' : name.characters.first.toUpperCase();
Widget fallback(BuildContext _) => Container(
color: pt.accentSoft,
alignment: Alignment.center,
child: Text(initial,
style: TextStyle(
color: pt.accent,
fontSize: size * 0.42,
fontWeight: FontWeight.w700)),
);
final url = p['avatarUrl'] as String?;
if (url == null || url.isEmpty) return Builder(builder: fallback);
return MxcAvatar(
mxcUri: Uri.tryParse(url),
client: client,
size: size,
borderRadius: BorderRadius.circular(size / 2),
placeholder: fallback,
);
}
}
String _participantSummary(List<Map<String, dynamic>> others) {
final names = others
.map((p) => (p['displayName'] as String? ?? '').split(' ').first)
.where((n) => n.isNotEmpty)
.toList();
if (names.isEmpty) return '${others.length} dabei';
if (names.length <= 2) return '${names.join(' und ')} ${names.length == 1 ? 'ist' : 'sind'} dabei';
return '${names.take(2).join(', ')} und ${names.length - 2} weitere';
}
/// Karte über dem Chatverlauf: „Gruppenanruf läuft · N dabei · Beitreten“.
/// Unsichtbar, solange niemand im Anruf ist oder man selbst schon drin ist.
class GroupCallBanner extends ConsumerWidget {
final Room room;
const GroupCallBanner({super.key, required this.room});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
final others =
otherCallParticipants(ref.watch(voiceParticipantsProvider(room.id)), room);
final call = ref.watch(voiceChannelProvider);
final show = !room.isDirectChat &&
others.isNotEmpty &&
!isInGroupCall(call, room.id);
return AnimatedSize(
duration: motionDuration(context, 260),
curve: springyCurve,
alignment: Alignment.topCenter,
child: !show
? const SizedBox(width: double.infinity)
: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 4),
child: Container(
padding: const EdgeInsets.fromLTRB(12, 10, 10, 10),
decoration: BoxDecoration(
gradient: LinearGradient(colors: [
pt.online.withValues(alpha: 0.20),
pt.online.withValues(alpha: 0.08),
]),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: pt.online.withValues(alpha: 0.35)),
),
child: Row(
children: [
CallAvatarStack(
participants: others,
client: room.client,
ringColor: pt.bg1,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Gruppenanruf läuft',
style: TextStyle(
color: pt.fg,
fontSize: 14,
fontWeight: FontWeight.w700)),
const SizedBox(height: 1),
Text(_participantSummary(others),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
TextStyle(color: pt.fgMuted, fontSize: 12.5)),
],
),
),
const SizedBox(width: 8),
SpringPress(
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: pt.online,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 10),
shape: const StadiumBorder(),
),
icon: const Icon(Icons.call_rounded, size: 18),
label: const Text('Beitreten',
style: TextStyle(fontWeight: FontWeight.w700)),
onPressed: () => startOrJoinGroupCall(context, ref, room),
),
),
],
),
),
),
);
}
}
/// Darstellung der „Gruppenanruf gestartet“-Nachricht im Verlauf.
class GroupCallNotice extends ConsumerWidget {
final Event event;
const GroupCallNotice({super.key, required this.event});
/// Ist [event] eine Gruppenanruf-Nachricht von Pyramid?
static bool matches(Event event) =>
event.content[groupCallContentKey] is Map;
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
final room = event.room;
final info = event.content[groupCallContentKey] as Map;
final video = info['video'] == true;
final all = ref.watch(voiceParticipantsProvider(room.id));
final call = ref.watch(voiceChannelProvider);
final inThis = isInGroupCall(call, room.id);
// Alte Karten (gestern und früher) gehören zu längst beendeten Anrufen.
final recent = DateTime.now().difference(event.originServerTs) <
const Duration(hours: 12);
final running = recent && (all.isNotEmpty || inThis);
return Container(
margin: const EdgeInsets.symmetric(vertical: 2),
padding: const EdgeInsets.fromLTRB(10, 8, 8, 8),
constraints: const BoxConstraints(maxWidth: 340),
decoration: BoxDecoration(
color: running ? pt.online.withValues(alpha: 0.12) : pt.bg2,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: running ? pt.online.withValues(alpha: 0.35) : pt.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: running ? pt.online : pt.bg3,
),
child: Icon(video ? Icons.videocam_rounded : Icons.call_rounded,
size: 18, color: running ? Colors.black : pt.fgMuted),
),
const SizedBox(width: 10),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(video ? 'Videoanruf gestartet' : 'Gruppenanruf gestartet',
style: TextStyle(
color: pt.fg,
fontSize: 13.5,
fontWeight: FontWeight.w700)),
Text(
running
? (inThis ? 'Du bist dabei' : 'Läuft · ${all.length} dabei')
: 'Beendet',
style: TextStyle(color: pt.fgMuted, fontSize: 12),
),
],
),
),
if (running && !inThis) ...[
const SizedBox(width: 10),
TextButton(
style: TextButton.styleFrom(
foregroundColor: pt.online,
textStyle: const TextStyle(fontWeight: FontWeight.w700),
),
onPressed: () =>
startOrJoinGroupCall(context, ref, room, video: video),
child: const Text('Beitreten'),
),
],
],
),
);
}
}