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
+28
View File
@@ -6,3 +6,31 @@ const voiceChannelRoomType = 'io.pyramid.voice_channel';
/// Ist [room] ein Sprachkanal (statt eines Text-Chats)?
bool isVoiceChannelRoom(Room room) =>
room.getState(EventTypes.RoomCreate)?.content['type'] == voiceChannelRoomType;
/// IDs aller Räume, die Kanal eines beigetretenen Servers (Space) sind –
/// auch über Unter-Spaces. Diese Räume stehen nur in ihrem Server, nicht
/// zusätzlich in „Chats“ (Bernd 2026-10-09: „Alles in Chats“).
Set<String> spaceChannelIds(Client client) {
final ids = <String>{};
for (final space in client.rooms) {
if (!space.isSpace || space.membership != Membership.join) continue;
for (final child in space.spaceChildren) {
final id = child.roomId;
if (id != null) ids.add(id);
}
}
return ids;
}
/// Gehört [room] in die gemeinsame Chatliste? Direktnachrichten und Gruppen,
/// die zu keinem beigetretenen Server gehören. [channelIds] stammt aus
/// [spaceChannelIds] (einmal pro Liste berechnen, nicht pro Raum).
bool isChatListRoom(Room room, Set<String> channelIds) {
if (room.isSpace) return false;
if (room.isDirectChat) return true;
return !channelIds.contains(room.id);
}
/// Gruppenchat = kein Space, keine Direktnachricht, kein Sprachkanal.
bool isGroupChatRoom(Room room) =>
!room.isSpace && !room.isDirectChat && !isVoiceChannelRoom(room);
+201
View File
@@ -0,0 +1,201 @@
import 'dart:math' as math;
import 'dart:ui';
/// Physik der Bubble-Anrufansicht (Bernd 2026-10-09: „animierte dynamische
/// Bubbles, die sich aneinander anpassen und aussehen, als würden sie
/// aneinander gedrückt“).
///
/// Jede Person ist ein Kreis. Eine Feder zieht alle Kreise zur Mitte, Kreise
/// stoßen sich ab, sobald sie sich mehr als [overlap] überlappen, und die
/// Ränder halten sie im Bild. Im Gleichgewicht drücken sie also leicht
/// gegeneinander; [clipPath] schneidet jeden Kreis an der Berührungslinie
/// (Potenzgerade) gerade ab – wie Seifenblasen in Schaum.
///
/// Wer spricht, wird größer und schiebt die anderen sanft beiseite; neue
/// Teilnehmer wachsen aus dem Nichts heraus, Gehende schrumpfen weg.
class BubbleBody {
final String id;
Offset pos;
Offset vel = Offset.zero;
double r;
double rVel = 0;
double targetR = 0;
bool leaving = false;
BubbleBody(this.id, this.pos, {this.r = 0});
}
class BubbleSim {
/// Anteil der Kreisdurchmesser, um den sich Nachbarn überlappen dürfen –
/// daraus entstehen die flachen Berührungsflächen.
static const overlap = 0.10;
/// Wie viel größer ein sprechender Kreis wird.
static const speakingScale = 1.22;
final Map<String, BubbleBody> bodies = {};
Size _size = Size.zero;
Size get size => _size;
Offset get _center => Offset(_size.width / 2, _size.height / 2);
/// Gleicht die Kreise mit den aktuellen Teilnehmern ab: neue kommen dazu,
/// fehlende schrumpfen weg, die Zielgrößen werden neu verteilt.
/// [speaking] = IDs, die gerade sprechen.
void sync(List<String> ids, Set<String> speaking, Size size) {
_size = size;
final present = ids.toSet();
for (final b in bodies.values) {
b.leaving = !present.contains(b.id);
}
for (final id in ids) {
if (bodies.containsKey(id)) continue;
// Neu: nahe der Mitte (leicht versetzt, damit es die anderen
// auseinanderschiebt statt exakt auf ihnen zu liegen).
final h = id.hashCode;
final angle = (h % 360) * math.pi / 180;
final spread = math.min(size.width, size.height) * 0.08;
bodies[id] = BubbleBody(
id,
_center + Offset(math.cos(angle), math.sin(angle)) * spread,
);
}
_assignTargets(ids, speaking);
}
void _assignTargets(List<String> ids, Set<String> speaking) {
final w = _size.width, h = _size.height;
if (w <= 0 || h <= 0) return;
final shortSide = math.min(w, h);
double weight(String id) => speaking.contains(id) ? speakingScale : 1.0;
final active = ids.where((id) => bodies.containsKey(id)).toList();
double base;
if (active.length <= 1) {
base = shortSide * 0.30;
} else {
// Kreise sollen zusammen gut 40 % der Fläche bedecken.
final sumW2 = active.fold<double>(0, (s, id) => s + weight(id) * weight(id));
base = math.sqrt(0.42 * w * h / (math.pi * sumW2));
}
final maxR = shortSide * (active.length <= 2 ? 0.30 : 0.36);
for (final b in bodies.values) {
if (b.leaving) {
b.targetR = 0;
} else {
b.targetR = (base * weight(b.id)).clamp(28.0, maxR);
}
}
}
/// Ein Zeitschritt in Sekunden. Liefert `true`, solange sich noch etwas
/// bewegt (sonst kann die Animation pausieren).
bool step(double dt) {
if (_size.isEmpty) return false;
dt = dt.clamp(0.0, 1 / 30);
// Zwei Unterschritte: stabil auch bei großen Kräften.
var moving = false;
for (var i = 0; i < 2; i++) {
moving = _substep(dt / 2) || moving;
}
bodies.removeWhere((_, b) => b.leaving && b.r < 1);
return moving;
}
bool _substep(double dt) {
const centerK = 18.0; // Zug zur Mitte
const pushK = 260.0; // Abstoßung bei zu starker Überlappung
const wallK = 160.0;
const damping = 9.0;
const radiusK = 110.0;
const radiusDamping = 13.0;
const margin = 10.0;
final list = bodies.values.toList();
final forces = List<Offset>.filled(list.length, Offset.zero);
final c = _center;
for (var i = 0; i < list.length; i++) {
final a = list[i];
// Die Mitte zieht, aber gestaucht in der langen Richtung, damit sich
// die Kreise im Hochformat übereinander, im Querformat nebeneinander
// anordnen.
final d = a.pos - c;
final ax = _size.width >= _size.height ? 0.55 : 1.0;
final ay = _size.height > _size.width ? 0.55 : 1.0;
forces[i] += Offset(-d.dx * centerK * ax, -d.dy * centerK * ay);
// Ränder
final left = a.pos.dx - a.r - margin;
final right = _size.width - margin - (a.pos.dx + a.r);
final top = a.pos.dy - a.r - margin;
final bottom = _size.height - margin - (a.pos.dy + a.r);
if (left < 0) forces[i] += Offset(-left * wallK, 0);
if (right < 0) forces[i] += Offset(right * wallK, 0);
if (top < 0) forces[i] += Offset(0, -top * wallK);
if (bottom < 0) forces[i] += Offset(0, bottom * wallK);
for (var j = i + 1; j < list.length; j++) {
final b = list[j];
var delta = b.pos - a.pos;
var dist = delta.distance;
if (dist < 0.01) {
// exakt übereinander: in eine feste Richtung auseinander
delta = Offset(math.cos(i + j * 1.7), math.sin(i + j * 1.7));
dist = 0.01;
}
final rest = (a.r + b.r) * (1 - overlap);
if (dist < rest) {
final u = delta / dist;
final f = u * ((rest - dist) * pushK);
forces[i] -= f;
forces[j] += f;
}
}
}
var moving = false;
final decay = math.exp(-damping * dt);
for (var i = 0; i < list.length; i++) {
final b = list[i];
b.vel = (b.vel + forces[i] * dt) * decay;
b.pos += b.vel * dt;
// Größe federt zum Ziel (leichtes Überschwingen beim Erscheinen).
b.rVel += (radiusK * (b.targetR - b.r) - radiusDamping * b.rVel) * dt;
b.r = math.max(0, b.r + b.rVel * dt);
if (b.vel.distance > 2 ||
b.rVel.abs() > 2 ||
(b.targetR - b.r).abs() > 0.5) {
moving = true;
}
}
return moving;
}
/// Umriss von [id] in Bühnen-Koordinaten: der Kreis, an jeder Berührung
/// mit einem Nachbarn gerade abgeschnitten (mit [seam] Pixel Fuge).
Path clipPath(String id, {double seam = 3}) {
final a = bodies[id]!;
final circle = Rect.fromCircle(center: a.pos, radius: math.max(a.r, 0.01));
var path = Path()..addOval(circle);
for (final b in bodies.values) {
if (identical(a, b) || b.r < 1) continue;
final delta = b.pos - a.pos;
final d = delta.distance;
if (d < 0.01 || d >= a.r + b.r) continue;
final u = delta / d;
// Abstand von a zur Potenzgerade der beiden Kreise
final cut = (d * d + a.r * a.r - b.r * b.r) / (2 * d) - seam / 2;
if (cut >= a.r) continue;
final n = Offset(-u.dy, u.dx);
final big = a.r * 4 + 10;
final base = a.pos + u * cut;
final half = Path()
..addPolygon([
base + n * big,
base - n * big,
base - n * big - u * big,
base + n * big - u * big,
], true);
path = Path.combine(PathOperation.intersect, path, half);
}
return path;
}
}
+305
View File
@@ -0,0 +1,305 @@
part of '../voice_channel.dart';
/// Anrufansicht als aneinandergedrückte Bubbles (Physik: bubble_physics.dart).
/// Jede Person ist eine Blase mit Profilbild (oder Kamera-Bild), wer spricht,
/// wächst und leuchtet; Blasen weichen sich federnd aus.
class _BubbleStage extends StatefulWidget {
final List<_ParticipantData> participants;
final Client? client;
final PyramidTheme pt;
final ValueChanged<_ParticipantData> onSelect;
const _BubbleStage({
super.key,
required this.participants,
required this.client,
required this.pt,
required this.onSelect,
});
@override
State<_BubbleStage> createState() => _BubbleStageState();
}
class _BubbleStageState extends State<_BubbleStage>
with SingleTickerProviderStateMixin {
final _sim = BubbleSim();
// Letzter bekannter Stand je Person – auch für Gehende, die noch
// wegschrumpfen.
final Map<String, _ParticipantData> _data = {};
late final Ticker _ticker = createTicker(_tick);
Duration? _last;
double _time = 0;
bool _needsSync = true;
@override
void didUpdateWidget(_BubbleStage old) {
super.didUpdateWidget(old);
_needsSync = true;
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
void _tick(Duration elapsed) {
final dt = _last == null
? 1 / 60
: (elapsed - _last!).inMicroseconds / Duration.microsecondsPerSecond;
_last = elapsed;
_time += dt;
final moving = _sim.step(dt);
_data.removeWhere((id, _) => !_sim.bodies.containsKey(id));
final speaking = widget.participants.any((p) => p.isSpeaking);
setState(() {});
// Ruhe = keine Animation (spart Akku); Sprechen hält das Leuchten an.
if (!moving && !speaking) {
_ticker.stop();
_last = null;
}
}
void _sync(Size size) {
for (final p in widget.participants) {
_data[p.id] = p;
}
_sim.sync(
widget.participants.map((p) => p.id).toList(),
widget.participants.where((p) => p.isSpeaking).map((p) => p.id).toSet(),
size,
);
if (!_ticker.isActive) {
_last = null;
_ticker.start();
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return LayoutBuilder(builder: (context, box) {
final size = box.biggest;
if (_needsSync || size != _sim.size) {
_needsSync = false;
_sync(size);
}
final glow = 0.5 + 0.5 * math.sin(_time * 6);
final children = <Widget>[];
for (final b in _sim.bodies.values) {
final d = _data[b.id];
if (d == null || b.r < 2) continue;
final rect = Rect.fromCircle(center: b.pos, radius: b.r);
children.add(Positioned.fromRect(
key: ValueKey('bubble:${b.id}'),
rect: rect,
child: _Bubble(
data: d,
path: _sim.clipPath(b.id).shift(-rect.topLeft),
radius: b.r,
client: widget.client,
pt: pt,
glow: glow,
onTap: () => widget.onSelect(d),
),
));
}
return DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
colors: [pt.bg2, pt.bg1],
radius: 0.9,
),
),
child: Stack(clipBehavior: Clip.hardEdge, children: children),
);
});
}
}
class _Bubble extends StatelessWidget {
final _ParticipantData data;
final Path path;
final double radius;
final Client? client;
final PyramidTheme pt;
final double glow;
final VoidCallback onTap;
const _Bubble({
required this.data,
required this.path,
required this.radius,
required this.client,
required this.pt,
required this.glow,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final showName = radius > 38;
return Semantics(
label: data.isMuted ? '${data.name}, stumm' : data.name,
button: data.hasVideo,
child: GestureDetector(
onTap: onTap,
child: CustomPaint(
foregroundPainter: _BubbleRingPainter(
path: path,
speaking: data.isSpeaking,
glow: glow,
color: pt.online,
),
child: ClipPath(
clipper: _PathClipper(path),
child: Stack(
fit: StackFit.expand,
children: [
_face(),
if (data.hasVideo && data.coverVideoWidget != null)
_MirrorWrapper(
isLocal: data.isLocal,
isScreenShare: data.isScreenShare,
child: data.coverVideoWidget!,
),
if (showName)
Align(
alignment: const Alignment(0, 0.62),
child: _nameTag(),
),
],
),
),
),
),
);
}
/// Profilbild oder Farbverlauf mit Anfangsbuchstaben – fest in 256 px
/// gebaut und skaliert, damit das Bild beim Wachsen nicht neu lädt.
Widget _face() {
Widget initial(BuildContext _) => Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [data.avatarColor, data.avatarColor.withAlpha(150)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
alignment: const Alignment(0, -0.12),
child: Text(data.initial,
style: const TextStyle(
color: Colors.white,
fontSize: 104,
fontWeight: FontWeight.w700)),
);
final c = client;
final face = (c != null && data.avatarUrl != null)
? MxcAvatar(
mxcUri: data.avatarUrl,
client: c,
size: 256,
borderRadius: BorderRadius.zero,
placeholder: initial,
)
: Builder(builder: initial);
return FittedBox(
fit: BoxFit.cover,
child: SizedBox(width: 256, height: 256, child: face),
);
}
Widget _nameTag() {
final fontSize = (radius * 0.16).clamp(10.0, 15.0);
return Container(
constraints: BoxConstraints(maxWidth: radius * 1.5),
padding: EdgeInsets.symmetric(
horizontal: fontSize * 0.7, vertical: fontSize * 0.25),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (data.isMuted) ...[
Icon(Icons.mic_off_rounded, size: fontSize + 1, color: pt.danger),
SizedBox(width: fontSize * 0.3),
],
Flexible(
child: Text(
data.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: fontSize,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
class _PathClipper extends CustomClipper<Path> {
final Path path;
_PathClipper(this.path);
@override
Path getClip(Size size) => path;
@override
bool shouldReclip(_PathClipper old) => !identical(old.path, path);
}
class _BubbleRingPainter extends CustomPainter {
final Path path;
final bool speaking;
final double glow;
final Color color;
_BubbleRingPainter({
required this.path,
required this.speaking,
required this.glow,
required this.color,
});
@override
void paint(Canvas canvas, Size size) {
if (speaking) {
canvas.drawPath(
path,
Paint()
..color = color.withValues(alpha: 0.30 + 0.30 * glow)
..style = PaintingStyle.stroke
..strokeWidth = 7
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6),
);
canvas.drawPath(
path,
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 3
..strokeJoin = StrokeJoin.round,
);
} else {
canvas.drawPath(
path,
Paint()
..color = Colors.white.withValues(alpha: 0.10)
..style = PaintingStyle.stroke
..strokeWidth = 1.2,
);
}
}
@override
bool shouldRepaint(_BubbleRingPainter old) => true;
}
+145 -37
View File
@@ -1,7 +1,13 @@
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;
@@ -22,6 +28,8 @@ class _UnifiedCallView extends StatefulWidget {
const _UnifiedCallView({
required this.participants,
required this.client,
required this.startedAt,
required this.isLiveKit,
required this.displayName,
required this.isConnecting,
@@ -46,31 +54,49 @@ class _UnifiedCallView extends StatefulWidget {
}
class _UnifiedCallViewState extends State<_UnifiedCallView> {
_CallLayout _mode = _CallLayout.bubbles;
String? _heroId;
bool _isGridView = false;
bool _stripCollapsed = false;
Set<String> _knownScreens = {};
@override
void initState() {
super.initState();
_autoSelectHero();
_followScreenShares();
}
@override
void didUpdateWidget(_UnifiedCallView oldWidget) {
super.didUpdateWidget(oldWidget);
_autoSelectHero();
_followScreenShares();
}
void _autoSelectHero() {
if (_isGridView) return;
final screenshare = widget.participants.firstWhereOrNull((p) => p.isScreenShare);
if (screenshare != null && (_heroId == null || !widget.participants.any((p) => p.id == _heroId && p.hasVideo))) {
_heroId = screenshare.id;
} else if (_heroId == null && widget.participants.isNotEmpty) {
final firstVideo = widget.participants.firstWhereOrNull((p) => p.hasVideo);
if (firstVideo != null) _heroId = firstVideo.id;
/// 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) {
@@ -87,15 +113,10 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
Widget build(BuildContext context) {
final pt = widget.pt;
final all = _sorted(widget.participants);
// Ohne gewählten Hero nicht nach „spricht gerade“ wählen – sonst springt
// das große Bild bei jedem Sprechwechsel hin und her.
final stable = [...widget.participants]..sort((a, b) {
int r(_ParticipantData p) => p.isScreenShare ? 0 : (p.hasVideo ? 1 : 2);
return r(a).compareTo(r(b));
});
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ??
(stable.isNotEmpty ? stable.first : null);
final hero = _mode == _CallLayout.hero
? widget.participants.firstWhereOrNull((p) => p.id == _heroId)
: null;
if (widget.isConnecting) {
return Scaffold(
backgroundColor: pt.bg1,
@@ -126,23 +147,44 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
children: [
_CallHeader(
displayName: widget.displayName,
isGridView: _isGridView,
onToggleView: () => setState(() => _isGridView = !_isGridView),
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: const Duration(milliseconds: 300),
child: _isGridView || hero == null
? _ParticipantGrid(participants: all, pt: pt, onSelect: (p) => setState(() { _heroId = p.id; _isGridView = false; }))
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) => setState(() => _heroId = p.id),
onSelectOther: (p) => p.hasVideo
? setState(() => _heroId = p.id)
: null,
onQualityMenu: widget.onQualityMenu,
pt: pt,
),
@@ -170,30 +212,55 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
class _CallHeader extends StatelessWidget {
final String displayName;
final bool isGridView;
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.isGridView, required this.onToggleView, required this.onMinimize, required this.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: 52,
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
child: Row(
children: [
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
const SizedBox(width: 8),
Text(displayName, style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
_LiveBadge(pt: pt),
const Spacer(),
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(isGridView ? Icons.grid_view_rounded : Icons.featured_video_rounded, color: isGridView ? pt.accent : pt.fg),
tooltip: isGridView ? 'Großansicht' : 'Kachelansicht',
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),
@@ -208,6 +275,47 @@ class _CallHeader extends StatelessWidget {
}
}
/// 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});
@@ -5,7 +5,7 @@ class _ParticipantGrid extends StatelessWidget {
final PyramidTheme pt;
final Function(_ParticipantData) onSelect;
const _ParticipantGrid({required this.participants, required this.pt, required this.onSelect});
const _ParticipantGrid({super.key, required this.participants, required this.pt, required this.onSelect});
@override
Widget build(BuildContext context) {
@@ -41,6 +41,7 @@ class _HeroLayout extends StatelessWidget {
final PyramidTheme pt;
const _HeroLayout({
super.key,
required this.hero,
required this.others,
required this.stripCollapsed,
@@ -14,6 +14,12 @@ class _ParticipantData {
final double aspectRatio;
/// Woraus der Anfangsbuchstabe im Avatar gebildet wird (Standard: [name]).
final String? initialFrom;
/// Profilbild (mxc://) der Person, falls bekannt.
final Uri? avatarUrl;
/// Bin das ich?
final bool isLocal;
/// Kamera-/Bildschirmbild formatfüllend (für die runden Bubbles).
final Widget? coverVideoWidget;
String get initial {
final src = (initialFrom ?? name).replaceFirst('@', '').trim();
@@ -32,9 +38,21 @@ class _ParticipantData {
this.originalParticipant,
this.aspectRatio = 16.0 / 9.0,
this.initialFrom,
this.avatarUrl,
this.isLocal = false,
this.coverVideoWidget,
});
}
/// Startzeit je Anruf (für die Dauer im Kopf). Schlüssel bleibt beim
/// Minimieren gleich, damit die Uhr nicht neu anfängt.
final _callStarts = <String, DateTime>{};
DateTime _callStartFor(String key) {
if (_callStarts.length > 20) _callStarts.clear();
return _callStarts.putIfAbsent(key, DateTime.now);
}
class VoiceChannelView extends ConsumerWidget {
const VoiceChannelView({super.key});
@@ -43,10 +61,13 @@ class VoiceChannelView extends ConsumerWidget {
final pt = PyramidTheme.of(context);
final call = ref.watch(voiceChannelProvider);
final voip = ref.watch(callSignalingProvider);
final client = ref.watch(matrixClientProvider).valueOrNull;
if (voip.currentCall != null && voip.currentCall!.state != CallState.kEnded) {
return _UnifiedCallView(
participants: _getVoipParticipants(voip),
client: client,
startedAt: _callStartFor('voip:${voip.currentCall!.callId}'),
isLiveKit: false,
displayName: voip.currentCall!.room.getLocalizedDisplayname(),
isConnecting: false,
@@ -76,7 +97,9 @@ class VoiceChannelView extends ConsumerWidget {
}
return _UnifiedCallView(
participants: _getLiveKitParticipants(call),
participants: _getLiveKitParticipants(call, client),
client: client,
startedAt: _callStartFor('lk:${call.currentRoomId ?? call.displayName}'),
isLiveKit: true,
displayName: call.displayName,
isConnecting: call.isConnecting,
@@ -101,9 +124,26 @@ class VoiceChannelView extends ConsumerWidget {
);
}
List<_ParticipantData> _getLiveKitParticipants(VoiceChannelService call) {
List<_ParticipantData> _getLiveKitParticipants(
VoiceChannelService call, Client? client) {
final room = call.room;
if (room == null) return [];
// LiveKit-Identität = Matrix-ID (vom Token-Server gesetzt) → Name und
// Profilbild aus dem Matrix-Raum des Anrufs.
final matrixRoomId = call.currentRoomId;
final matrixRoom =
matrixRoomId == null ? null : client?.getRoomById(matrixRoomId);
User? member(String id) {
if (!id.startsWith('@')) return null;
if (matrixRoom != null) {
return matrixRoom.unsafeGetUserFromMemoryOrFallback(id);
}
for (final r in client?.rooms ?? const []) {
final u = r.unsafeGetUserFromMemoryOrFallback(id);
if (u.avatarUrl != null || u.displayName != null) return u;
}
return null;
}
final all = <Participant>[
if (room.localParticipant != null) room.localParticipant!,
...room.remoteParticipants.values,
@@ -127,10 +167,20 @@ class VoiceChannelView extends ConsumerWidget {
} catch (_) {}
}
final isLocal = p.identity == room.localParticipant?.identity;
final user = member(p.identity);
final name = isLocal
? 'Du'
: (user?.calcDisplayname() ??
(p.name.isNotEmpty ? p.name : p.identity));
return _ParticipantData(
id: p.identity,
name: p.identity == room.localParticipant?.identity ? 'Du' : p.identity,
name: name,
initialFrom: isLocal ? user?.calcDisplayname() ?? p.identity : null,
avatarUrl: user?.avatarUrl,
isLocal: isLocal,
videoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.contain) : null,
coverVideoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.cover) : null,
isSpeaking: p.isSpeaking,
isMuted: !p.isMicrophoneEnabled(),
hasVideo: videoTrack != null,
@@ -151,6 +201,10 @@ class VoiceChannelView extends ConsumerWidget {
return (w > 0 && h > 0) ? w / h : 16.0 / 9.0;
}
final mroom = voip.currentCall?.room;
final partnerId = mroom?.directChatMatrixID;
final myId = mroom?.client.userID;
// Remote
final isRemoteScreen = voip.isRemoteScreensharing;
final hasRemoteVideo = voip.remoteRenderer.srcObject != null && voip.remoteRenderer.srcObject!.getVideoTracks().isNotEmpty;
@@ -158,6 +212,10 @@ class VoiceChannelView extends ConsumerWidget {
id: 'remote',
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Gegenseite',
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
coverVideoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover) : null,
avatarUrl: partnerId == null
? mroom?.avatar
: mroom?.unsafeGetUserFromMemoryOrFallback(partnerId).avatarUrl,
isMuted: false,
isSpeaking: voip.isRemoteSpeaking,
hasVideo: hasRemoteVideo,
@@ -174,6 +232,11 @@ class VoiceChannelView extends ConsumerWidget {
name: 'Du',
initialFrom: voip.currentCall?.room.client.userID?.split(':').first,
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
coverVideoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover) : null,
avatarUrl: myId == null
? null
: mroom?.unsafeGetUserFromMemoryOrFallback(myId).avatarUrl,
isLocal: true,
isMuted: voip.isMicMuted,
isSpeaking: voip.isLocalSpeaking,
hasVideo: hasLocalVideo,
+9
View File
@@ -1,9 +1,17 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show Ticker;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:collection/collection.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/features/call_ui/bubble_physics.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
import 'package:pyramid/widgets/screen_share_picker.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
@@ -17,3 +25,4 @@ part 'view/call_view.dart';
part 'view/participant_layout.dart';
part 'view/avatars_controls.dart';
part 'view/quality_menu.dart';
part 'view/bubble_stage.dart';
+4
View File
@@ -19,10 +19,14 @@ import 'package:pyramid/features/chat/chat_composer.dart';
import 'package:pyramid/features/chat/chat_provider.dart';
import 'package:pyramid/features/chat/message_group.dart';
import 'package:pyramid/features/chat/pinned_messages_panel.dart';
import 'package:pyramid/features/group_call/group_call.dart';
import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/measure_size.dart';
import 'package:pyramid/widgets/spring_press.dart';
import 'package:pyramid/core/read_markers.dart';
import 'package:pyramid/core/room_kinds.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart'
show spaceChannelIdsProvider;
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/core/notification_service.dart';
@@ -44,6 +44,10 @@ class _MessageContent extends StatelessWidget {
final displayEvent = _displayEvent;
final isEdited = displayEvent.eventId != event.eventId;
if (GroupCallNotice.matches(displayEvent)) {
return GroupCallNotice(event: displayEvent);
}
// Still encrypted (decryption pending or failed)
if (displayEvent.type == EventTypes.Encrypted) {
return Padding(
+1
View File
@@ -20,6 +20,7 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:pyramid/features/chat/document_viewer.dart';
import 'package:pyramid/features/chat/media_viewer.dart';
import 'package:pyramid/features/chat/reaction_service.dart';
import 'package:pyramid/features/group_call/group_call.dart';
import 'package:pyramid/utils/gif_favorite_service.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/spring_press.dart';
+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)
+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'),
),
],
],
),
);
}
}
+5 -10
View File
@@ -29,7 +29,7 @@ class _Header extends ConsumerWidget {
final name = isRealSpace
? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
: (spaceId == 'dms' ? 'Chats' : 'Räume');
: 'Chats';
void handleMenu(_SpaceMenuItem item) {
switch (item) {
@@ -101,7 +101,7 @@ class _Header extends ConsumerWidget {
).then((confirmed) {
if (confirmed == true) {
space.leave().then((_) {
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
});
}
});
@@ -110,7 +110,7 @@ class _Header extends ConsumerWidget {
}
List<PopupMenuEntry<_SpaceMenuItem>> buildMenuItems() {
if (spaceId == 'dms' || spaceId == null) {
if (!isRealSpace) {
return [
PopupMenuItem(
value: _SpaceMenuItem.newDm,
@@ -119,15 +119,10 @@ class _Header extends ConsumerWidget {
PopupMenuItem(
value: _SpaceMenuItem.createGroup,
child: _MenuRow(Icons.group_add_rounded, 'Gruppe erstellen', pt)),
];
} else if (spaceId == 'rooms') {
return [
PopupMenuItem(
value: _SpaceMenuItem.createRoom,
child: _MenuRow(Icons.add_rounded, 'Raum erstellen', pt)),
const PopupMenuDivider(),
PopupMenuItem(
value: _SpaceMenuItem.joinRoom,
child: _MenuRow(Icons.login_rounded, 'Beitreten', pt)),
child: _MenuRow(Icons.login_rounded, 'Raum beitreten', pt)),
PopupMenuItem(
value: _SpaceMenuItem.discoverRooms,
child: _MenuRow(Icons.explore_rounded, 'Entdecken', pt)),
+183 -120
View File
@@ -59,56 +59,66 @@ class _DraggableRoomItemState extends State<_DraggableRoomItem> {
}
}
/// Filter der gemeinsamen Chatliste (Chips über der Liste).
enum _ChatFilter { all, unread, people, groups }
final _chatFilterProvider =
StateProvider<_ChatFilter>((ref) => _ChatFilter.all);
/// „Chats“: Direktnachrichten und Gruppen zusammen, nach letzter Aktivität
/// (Bernd 2026-10-09). Kanäle eines Servers stehen nur in ihrem Server.
class _RoomsList extends ConsumerWidget {
final List<Room> rooms;
final String filter;
final String? spaceId;
final Map<String, bool> collapsed;
final PyramidTheme pt;
final ValueChanged<String> onToggleSection;
const _RoomsList({
required this.rooms,
required this.filter,
required this.spaceId,
required this.collapsed,
required this.pt,
required this.onToggleSection,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final activeRoomId = ref.watch(activeRoomIdProvider);
final isDms = spaceId == 'dms';
final channels = ref.watch(spaceChannelIdsProvider);
final chatFilter = ref.watch(_chatFilterProvider);
final query = filter.toLowerCase();
final invites = rooms
.where((r) =>
r.membership == Membership.invite &&
isDms == r.isDirectChat)
.toList();
final invites =
rooms.where((r) => r.membership == Membership.invite).toList();
final filtered = rooms.where((r) {
if (r.membership == Membership.invite) return false;
if (isDms != r.isDirectChat) return false;
if (filter.isEmpty) return true;
return r.getLocalizedDisplayname()
.toLowerCase()
.contains(filter.toLowerCase());
}).toList();
if (filtered.isEmpty && invites.isEmpty) {
return Center(
child:
Text(isDms ? 'Noch keine Chats' : 'Keine Räume', style: TextStyle(color: pt.fgDim, fontSize: 13)));
bool matches(Room r) {
switch (chatFilter) {
case _ChatFilter.all:
break;
case _ChatFilter.unread:
if (!r.isUnread) return false;
case _ChatFilter.people:
if (!r.isDirectChat) return false;
case _ChatFilter.groups:
if (r.isDirectChat) return false;
}
if (query.isEmpty) return true;
return r.getLocalizedDisplayname().toLowerCase().contains(query);
}
final sectionTitle = isDms ? 'Direktnachrichten' : 'Räume';
final sectionId = isDms ? 'dms' : 'rooms';
final isCollapsed = collapsed[sectionId] ?? false;
final chats = rooms
.where((r) =>
r.membership == Membership.join && isChatListRoom(r, channels))
.toList();
final filtered = chats.where(matches).toList();
final unreadCount = chats.where((r) => r.isUnread).length;
return ListView(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
children: [
_ChatFilterChips(
value: chatFilter,
unreadCount: unreadCount,
pt: pt,
onChanged: (f) => ref.read(_chatFilterProvider.notifier).state = f,
),
if (invites.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 2),
@@ -124,22 +134,18 @@ class _RoomsList extends ConsumerWidget {
child: _InviteItem(room: r, pt: pt))),
const SizedBox(height: 4),
],
_SectionHeader(
title: sectionTitle,
count: filtered.length,
isCollapsed: isCollapsed,
pt: pt,
onToggle: () => onToggleSection(sectionId),
onAddTap: isDms
? () => showDialog(
context: context, builder: (_) => const NewDmDialog())
: () => showDialog(
context: context, builder: (_) => const CreateJoinDialog()),
),
if (!isCollapsed)
if (filtered.isEmpty)
_ChatListEmpty(
filter: chatFilter,
searching: query.isNotEmpty,
hasInvites: invites.isNotEmpty,
pt: pt,
)
else
...filtered.map((room) {
final isVoice = _isVoiceRoom(room);
return _RoomItem(
key: ValueKey('chat:${room.id}'),
room: room,
isActive: isVoice ? false : activeRoomId == room.id,
isInVoice:
@@ -163,6 +169,136 @@ class _RoomsList extends ConsumerWidget {
}
}
class _ChatFilterChips extends StatelessWidget {
final _ChatFilter value;
final int unreadCount;
final PyramidTheme pt;
final ValueChanged<_ChatFilter> onChanged;
const _ChatFilterChips({
required this.value,
required this.unreadCount,
required this.pt,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
Widget chip(_ChatFilter f, String label) {
final selected = value == f;
return Padding(
padding: const EdgeInsets.only(right: 6),
child: Semantics(
button: true,
selected: selected,
child: GestureDetector(
onTap: () => onChanged(selected ? _ChatFilter.all : f),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: selected ? pt.accentSoft : pt.bg2,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: selected ? pt.accent.withAlpha(120) : pt.border),
),
child: Text(label,
style: TextStyle(
color: selected ? pt.accent : pt.fgMuted,
fontSize: 12.5,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500)),
),
),
),
);
}
return SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
children: [
chip(_ChatFilter.all, 'Alle'),
chip(_ChatFilter.unread,
unreadCount > 0 ? 'Ungelesen · $unreadCount' : 'Ungelesen'),
chip(_ChatFilter.people, 'Personen'),
chip(_ChatFilter.groups, 'Gruppen'),
],
),
);
}
}
/// Leerzustand der Chatliste – mit Handlung statt nur „nichts da“.
class _ChatListEmpty extends StatelessWidget {
final _ChatFilter filter;
final bool searching;
final bool hasInvites;
final PyramidTheme pt;
const _ChatListEmpty({
required this.filter,
required this.searching,
required this.hasInvites,
required this.pt,
});
@override
Widget build(BuildContext context) {
if (hasInvites && filter == _ChatFilter.all && !searching) {
return const SizedBox.shrink();
}
final (IconData icon, String text) = searching
? (Icons.search_off_rounded, 'Nichts gefunden')
: switch (filter) {
_ChatFilter.unread => (Icons.done_all_rounded, 'Alles gelesen'),
_ChatFilter.people => (
Icons.person_outline_rounded,
'Noch keine Unterhaltungen'
),
_ChatFilter.groups => (Icons.groups_outlined, 'Noch keine Gruppen'),
_ChatFilter.all => (
Icons.chat_bubble_outline_rounded,
'Noch keine Chats'
),
};
final showAction = !searching && filter != _ChatFilter.unread;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 40, 16, 16),
child: Column(
children: [
Icon(icon, size: 34, color: pt.fgDim),
const SizedBox(height: 10),
Text(text, style: TextStyle(color: pt.fgMuted, fontSize: 14)),
if (showAction) ...[
const SizedBox(height: 14),
FilledButton.tonalIcon(
style: FilledButton.styleFrom(
backgroundColor: pt.accentSoft,
foregroundColor: pt.accent,
),
icon: Icon(filter == _ChatFilter.groups
? Icons.group_add_rounded
: Icons.edit_rounded, size: 18),
label: Text(filter == _ChatFilter.groups
? 'Gruppe erstellen'
: 'Neue Unterhaltung'),
onPressed: () => showDialog(
context: context,
builder: (_) => filter == _ChatFilter.groups
? const CreateJoinDialog()
: const NewDmDialog(),
),
),
],
],
),
);
}
}
/// Beitreten/Verlassen eines Voice-Channels aus der Raumliste – exakt das
/// Verhalten von `joinVoiceChannel` in rooms_space_list.dart (Toggle beim
/// aktiven Kanal, Wechsel legt vorher auf).
@@ -187,84 +323,6 @@ Future<void> _toggleVoiceChannel(WidgetRef ref, Room room) async {
);
}
class _SectionHeader extends StatefulWidget {
final String title;
final int count;
final bool isCollapsed;
final PyramidTheme pt;
final VoidCallback onToggle;
final VoidCallback? onAddTap;
const _SectionHeader({
required this.title,
required this.count,
required this.isCollapsed,
required this.pt,
required this.onToggle,
this.onAddTap,
});
@override
State<_SectionHeader> createState() => _SectionHeaderState();
}
class _SectionHeaderState extends State<_SectionHeader> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Container(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
child: Row(children: [
Expanded(
child: GestureDetector(
onTap: widget.onToggle,
child: Row(children: [
AnimatedRotation(
turns: widget.isCollapsed ? -0.25 : 0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutBack,
child: Icon(Icons.keyboard_arrow_down_rounded,
size: 14,
color: _hovered ? pt.fgMuted : pt.fgDim),
),
const SizedBox(width: 4),
Text(widget.title.toUpperCase(),
style: TextStyle(
color: _hovered ? pt.fgMuted : pt.fgDim,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.04 * 11)),
const Spacer(),
Text('${widget.count}',
style: TextStyle(color: pt.fgDim, fontSize: 11)),
]),
),
),
if (_hovered && widget.onAddTap != null) ...[
const SizedBox(width: 4),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onAddTap,
child: SizedBox(
width: 18,
height: 18,
child: Center(
child: Icon(Icons.add, size: 12, color: pt.fgMuted)),
),
),
] else
const SizedBox(width: 22),
]),
),
);
}
}
class _RoomItem extends ConsumerStatefulWidget {
final Room room;
final bool isActive;
@@ -574,6 +632,11 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
children: [
if (isDm) ...[
_DmAvatar(room: room, pt: pt),
] else if (!isVoice &&
!ref
.watch(spaceChannelIdsProvider)
.contains(room.id)) ...[
_RoomTileAvatar(room: room, pt: pt, size: 22),
] else ...[
SizedBox(
width: 16,
+18 -11
View File
@@ -119,34 +119,41 @@ class _MobileRoomTile extends StatelessWidget {
}
}
/// Avatar für Räume (kein DM): Raumbild oder abgerundetes Symbol.
/// Avatar für Räume (kein DM): Raumbild oder Symbol. Gruppen (kein
/// Server-Kanal) sind rund wie Personen, Server-Kanäle abgerundet mit „#“.
class _RoomTileAvatar extends ConsumerWidget {
final Room room;
final PyramidTheme pt;
const _RoomTileAvatar({required this.room, required this.pt});
final double size;
const _RoomTileAvatar(
{required this.room, required this.pt, this.size = _kTileAvatar});
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(matrixClientProvider).valueOrNull;
final isChannel = ref.watch(spaceChannelIdsProvider).contains(room.id);
final radius = BorderRadius.circular(isChannel ? size * 0.3 : size / 2);
final fallback = Container(
width: _kTileAvatar,
height: _kTileAvatar,
width: size,
height: size,
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(14),
color: isChannel ? pt.bg3 : pt.accentSoft,
borderRadius: radius,
),
child: Icon(
room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
size: 22,
color: pt.fgMuted,
isChannel
? (room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded)
: Icons.groups_rounded,
size: size * 0.5,
color: isChannel ? pt.fgMuted : pt.accent,
),
);
if (client == null || room.avatar == null) return fallback;
return MxcAvatar(
mxcUri: room.avatar,
client: client,
size: _kTileAvatar,
borderRadius: BorderRadius.circular(14),
size: size,
borderRadius: radius,
placeholder: (_) => fallback,
);
}
@@ -118,12 +118,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
data: (rooms) => _RoomsList(
rooms: rooms,
filter: _filter,
spaceId: activeSpaceId,
collapsed: _collapsed,
pt: pt,
onToggleSection: (id) => setState(() {
_collapsed[id] = !(_collapsed[id] ?? false);
}),
),
),
),
@@ -40,7 +40,7 @@ class _SpaceInviteViewState extends ConsumerState<_SpaceInviteView> {
try {
await widget.space.leave();
if (mounted) {
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
}
} catch (_) {
if (mounted) setState(() => _declining = false);
+1
View File
@@ -10,6 +10,7 @@ import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/notification_content.dart' show notificationBody;
import 'package:pyramid/core/room_kinds.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart';
import 'package:pyramid/features/chat/chat_provider.dart' show acceptInvite;
+19 -4
View File
@@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/room_kinds.dart';
final roomListProvider = StreamProvider<List<Room>>((ref) async* {
final client = await ref.watch(matrixClientProvider.future);
@@ -12,19 +13,33 @@ final roomListProvider = StreamProvider<List<Room>>((ref) async* {
}
});
/// IDs aller Server-Kanäle (siehe [spaceChannelIds]); wird mit jeder
/// Synchronisation neu berechnet.
final spaceChannelIdsProvider = Provider<Set<String>>((ref) {
ref.watch(roomListProvider);
final client = ref.watch(matrixClientProvider).valueOrNull;
return client == null ? const {} : spaceChannelIds(client);
});
/// Ungelesene Unterhaltungen in „Chats“ (Direktnachrichten + Gruppen ohne
/// Server) – für das Abzeichen am Chats-Symbol.
final dmUnreadCountProvider = StreamProvider<int>((ref) async* {
final client = await ref.watch(matrixClientProvider.future);
yield _countDmUnread(client);
yield _countChatUnread(client);
await for (final _ in client.onSync.stream) {
yield _countDmUnread(client);
yield _countChatUnread(client);
}
});
int _countDmUnread(Client client) {
int _countChatUnread(Client client) {
final channels = spaceChannelIds(client);
return client.rooms
.where((r) => !r.isSpace && r.isDirectChat && r.notificationCount > 0)
.where((r) =>
r.membership == Membership.join &&
isChatListRoom(r, channels) &&
r.notificationCount > 0)
.length;
}
+4 -105
View File
@@ -67,7 +67,9 @@ class SpacesRail extends ConsumerWidget {
const SizedBox(height: 12),
_HomeButton(
pt: pt,
active: activeSpaceId == 'dms' || activeSpaceId == null,
active: activeSpaceId == 'dms' ||
activeSpaceId == 'rooms' ||
activeSpaceId == null,
unreadCount: dmUnread,
onTap: () => selectSpace('dms'),
),
@@ -86,18 +88,8 @@ class SpacesRail extends ConsumerWidget {
child: ListView(
padding: EdgeInsets.zero,
children: [
_VirtualSpaceItem(
id: 'rooms',
label: 'R',
color: const Color(0xFFF59E0B),
name: 'Räume',
active: activeSpaceId == 'rooms',
unread: 0,
pt: pt,
onTap: () => selectSpace('rooms'),
),
...spaces.map((space) => Padding(
padding: const EdgeInsets.only(top: 6),
padding: const EdgeInsets.only(bottom: 6),
child: _SpaceItem(
space: space,
active: activeSpaceId == space.id,
@@ -257,99 +249,6 @@ class _HomeButtonState extends State<_HomeButton> {
}
}
class _VirtualSpaceItem extends StatefulWidget {
final String id;
final String label;
final Color color;
final String name;
final bool active;
final int unread;
final PyramidTheme pt;
final VoidCallback onTap;
const _VirtualSpaceItem({
required this.id,
required this.label,
required this.color,
required this.name,
required this.active,
required this.unread,
required this.pt,
required this.onTap,
});
@override
State<_VirtualSpaceItem> createState() => _VirtualSpaceItemState();
}
class _VirtualSpaceItemState extends State<_VirtualSpaceItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final active = widget.active;
final radius = active
? pt.rLg * 0.6
: _hovered ? pt.rLg * 0.7 : pt.rLg;
return Center(
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Stack(
clipBehavior: Clip.none,
children: [
// Active indicator
if (active)
Positioned(
left: -18,
top: 10,
child: Container(
width: 4,
height: 28,
decoration: BoxDecoration(
color: pt.accent,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(3),
bottomRight: Radius.circular(3),
),
),
),
),
Tooltip(
message: widget.name,
waitDuration: const Duration(milliseconds: 400),
preferBelow: false,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutBack,
width: 48,
height: 48,
decoration: BoxDecoration(
color: _hovered ? pt.bg3 : pt.bg2,
borderRadius: BorderRadius.circular(radius),
),
child: Center(
child: PyramidGlyph(
size: 30,
color: widget.color,
label: widget.label,
),
),
),
),
],
),
),
),
);
}
}
class _SpaceItem extends ConsumerStatefulWidget {
final Room space;
final bool active;
@@ -287,7 +287,9 @@ class LiveKitCallManager extends VoiceChannelService {
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
unawaited(CallForeground.callStarted('livekit', title: roomDisplayName));
if (voiceChannel && matrixClient != null && matrixRoomId != null) {
// Sprachkanäle UND Gruppenanrufe: Anwesenheit im Matrix-Raum, damit
// die anderen sehen, wer drin ist (Gruppen: „Beitreten“-Karte).
if (matrixClient != null && matrixRoomId != null) {
await _writePresence(true);
_presenceHeartbeat = Timer.periodic(const Duration(seconds: 30), (_) {
_writePresence(true).catchError((_) {});
+8 -10
View File
@@ -6,6 +6,8 @@ import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/group_call/group_call.dart'
show allowVoicePresenceForAll;
import 'package:pyramid/widgets/mxc_image.dart';
enum CreateJoinTab { create, join, discover }
@@ -100,15 +102,7 @@ class _CreateJoinDialogState extends ConsumerState<CreateJoinDialog> {
// weil Continuwuity power_level_content_override beim createRoom
// ignoriert.
try {
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? ?? {});
events['io.pyramid.voice.presence'] = 0;
pl['events'] = events;
await client.setRoomStateWithKey(
roomId, EventTypes.RoomPowerLevels, '', pl);
await allowVoicePresenceForAll(client, roomId);
} catch (_) {
// Nicht kritisch — Presence-Anzeige funktioniert dann nur für Admins.
}
@@ -123,6 +117,10 @@ class _CreateJoinDialogState extends ConsumerState<CreateJoinDialog> {
? [StateEvent(content: {'algorithm': 'm.megolm.v1.aes-sha2'}, type: EventTypes.Encryption)]
: null,
);
// Gruppenanrufe: alle Mitglieder dürfen ihre Anwesenheit zeigen.
try {
await allowVoicePresenceForAll(client, roomId);
} catch (_) {}
}
if (mounted) {
ref.read(activeRoomIdProvider.notifier).state = roomId;
@@ -581,7 +579,7 @@ class _SpaceEditDialogState extends ConsumerState<SpaceEditDialog> {
try {
await widget.space.leave();
if (mounted) {
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
Navigator.of(context).pop(true);
}
} catch (e) {