- 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]>
363 lines
14 KiB
Dart
363 lines
14 KiB
Dart
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'),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|