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 allowVoicePresenceForAll(Client client, String roomId) async { final plRaw = await client.getRoomStateWithKey(roomId, EventTypes.RoomPowerLevels, ''); final pl = Map.from(plRaw); final events = Map.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 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> otherCallParticipants( List> 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 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> 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 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> 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'), ), ], ], ), ); } }