diff --git a/lib/core/room_kinds.dart b/lib/core/room_kinds.dart index 2daf212..b3ae3b6 100644 --- a/lib/core/room_kinds.dart +++ b/lib/core/room_kinds.dart @@ -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 spaceChannelIds(Client client) { + final ids = {}; + 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 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); diff --git a/lib/features/call_ui/bubble_physics.dart b/lib/features/call_ui/bubble_physics.dart new file mode 100644 index 0000000..e366452 --- /dev/null +++ b/lib/features/call_ui/bubble_physics.dart @@ -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 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 ids, Set 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 ids, Set 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(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.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; + } +} diff --git a/lib/features/call_ui/view/bubble_stage.dart b/lib/features/call_ui/view/bubble_stage.dart new file mode 100644 index 0000000..4db1f70 --- /dev/null +++ b/lib/features/call_ui/view/bubble_stage.dart @@ -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 _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 = []; + 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 { + 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; +} diff --git a/lib/features/call_ui/view/call_view.dart b/lib/features/call_ui/view/call_view.dart index 38b1dde..95852f7 100644 --- a/lib/features/call_ui/view/call_view.dart +++ b/lib/features/call_ui/view/call_view.dart @@ -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 _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}); diff --git a/lib/features/call_ui/view/participant_layout.dart b/lib/features/call_ui/view/participant_layout.dart index 4dc1f19..482726b 100644 --- a/lib/features/call_ui/view/participant_layout.dart +++ b/lib/features/call_ui/view/participant_layout.dart @@ -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, diff --git a/lib/features/call_ui/view/voice_channel_core.dart b/lib/features/call_ui/view/voice_channel_core.dart index 20fd010..16f96b8 100644 --- a/lib/features/call_ui/view/voice_channel_core.dart +++ b/lib/features/call_ui/view/voice_channel_core.dart @@ -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 = {}; + +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 = [ 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, diff --git a/lib/features/call_ui/voice_channel.dart b/lib/features/call_ui/voice_channel.dart index fbf2350..f1cd2b5 100644 --- a/lib/features/call_ui/voice_channel.dart +++ b/lib/features/call_ui/voice_channel.dart @@ -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'; diff --git a/lib/features/chat/chat_view.dart b/lib/features/chat/chat_view.dart index 8feba17..bfc9c7b 100644 --- a/lib/features/chat/chat_view.dart +++ b/lib/features/chat/chat_view.dart @@ -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'; diff --git a/lib/features/chat/message/message_text.dart b/lib/features/chat/message/message_text.dart index 38369a0..76a8063 100644 --- a/lib/features/chat/message/message_text.dart +++ b/lib/features/chat/message/message_text.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( diff --git a/lib/features/chat/message_group.dart b/lib/features/chat/message_group.dart index 799f707..5d41405 100644 --- a/lib/features/chat/message_group.dart +++ b/lib/features/chat/message_group.dart @@ -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'; diff --git a/lib/features/chat/view/chat_header.dart b/lib/features/chat/view/chat_header.dart index 22fd224..6f3a542 100644 --- a/lib/features/chat/view/chat_header.dart +++ b/lib/features/chat/view/chat_header.dart @@ -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; diff --git a/lib/features/chat/view/chat_view_core.dart b/lib/features/chat/view/chat_view_core.dart index d0adcea..1b446c7 100644 --- a/lib/features/chat/view/chat_view_core.dart +++ b/lib/features/chat/view/chat_view_core.dart @@ -521,6 +521,8 @@ class _ChatViewState extends ConsumerState { ), const _ConnectionBanner(), const _ActiveCallBar(), + if (room != null && !isInvite) + GroupCallBanner(room: room), Expanded( child: isInvite ? _InvitePanel(room: room!, pt: pt) diff --git a/lib/features/group_call/group_call.dart b/lib/features/group_call/group_call.dart new file mode 100644 index 0000000..3aff92c --- /dev/null +++ b/lib/features/group_call/group_call.dart @@ -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 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'), + ), + ], + ], + ), + ); + } +} diff --git a/lib/features/rooms/panel/rooms_header.dart b/lib/features/rooms/panel/rooms_header.dart index b06b4f4..c832deb 100644 --- a/lib/features/rooms/panel/rooms_header.dart +++ b/lib/features/rooms/panel/rooms_header.dart @@ -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> 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)), diff --git a/lib/features/rooms/panel/rooms_list.dart b/lib/features/rooms/panel/rooms_list.dart index 3aee58e..cc25e3d 100644 --- a/lib/features/rooms/panel/rooms_list.dart +++ b/lib/features/rooms/panel/rooms_list.dart @@ -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 rooms; final String filter; - final String? spaceId; - final Map collapsed; final PyramidTheme pt; - final ValueChanged 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 _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, diff --git a/lib/features/rooms/panel/rooms_mobile_tile.dart b/lib/features/rooms/panel/rooms_mobile_tile.dart index 2b6477a..cc4b2b2 100644 --- a/lib/features/rooms/panel/rooms_mobile_tile.dart +++ b/lib/features/rooms/panel/rooms_mobile_tile.dart @@ -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, ); } diff --git a/lib/features/rooms/panel/rooms_panel_core.dart b/lib/features/rooms/panel/rooms_panel_core.dart index 102bdea..e96a132 100644 --- a/lib/features/rooms/panel/rooms_panel_core.dart +++ b/lib/features/rooms/panel/rooms_panel_core.dart @@ -118,12 +118,7 @@ class _RoomsPanelState extends ConsumerState { data: (rooms) => _RoomsList( rooms: rooms, filter: _filter, - spaceId: activeSpaceId, - collapsed: _collapsed, pt: pt, - onToggleSection: (id) => setState(() { - _collapsed[id] = !(_collapsed[id] ?? false); - }), ), ), ), diff --git a/lib/features/rooms/panel/rooms_space_invite.dart b/lib/features/rooms/panel/rooms_space_invite.dart index 71a7a0d..c3b019e 100644 --- a/lib/features/rooms/panel/rooms_space_invite.dart +++ b/lib/features/rooms/panel/rooms_space_invite.dart @@ -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); diff --git a/lib/features/rooms/rooms_panel.dart b/lib/features/rooms/rooms_panel.dart index c819438..a5da318 100644 --- a/lib/features/rooms/rooms_panel.dart +++ b/lib/features/rooms/rooms_panel.dart @@ -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; diff --git a/lib/features/rooms/rooms_provider.dart b/lib/features/rooms/rooms_provider.dart index 999aa47..cae9a3c 100644 --- a/lib/features/rooms/rooms_provider.dart +++ b/lib/features/rooms/rooms_provider.dart @@ -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>((ref) async* { final client = await ref.watch(matrixClientProvider.future); @@ -12,19 +13,33 @@ final roomListProvider = StreamProvider>((ref) async* { } }); +/// IDs aller Server-Kanäle (siehe [spaceChannelIds]); wird mit jeder +/// Synchronisation neu berechnet. +final spaceChannelIdsProvider = Provider>((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((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; } diff --git a/lib/features/spaces/spaces_rail.dart b/lib/features/spaces/spaces_rail.dart index d5a31b0..43872c4 100644 --- a/lib/features/spaces/spaces_rail.dart +++ b/lib/features/spaces/spaces_rail.dart @@ -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; diff --git a/lib/features/voice_channel/livekit_call_manager.dart b/lib/features/voice_channel/livekit_call_manager.dart index 0e1769f..458f15a 100644 --- a/lib/features/voice_channel/livekit_call_manager.dart +++ b/lib/features/voice_channel/livekit_call_manager.dart @@ -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((_) {}); diff --git a/lib/widgets/create_join_dialog.dart b/lib/widgets/create_join_dialog.dart index 493e6cd..a0bcc40 100644 --- a/lib/widgets/create_join_dialog.dart +++ b/lib/widgets/create_join_dialog.dart @@ -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 { // weil Continuwuity power_level_content_override beim createRoom // ignoriert. try { - final plRaw = await client.getRoomStateWithKey( - roomId, EventTypes.RoomPowerLevels, ''); - final pl = Map.from(plRaw); - final events = - Map.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 { ? [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 { 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) { diff --git a/test/bubble_physics_test.dart b/test/bubble_physics_test.dart new file mode 100644 index 0000000..df4bfe8 --- /dev/null +++ b/test/bubble_physics_test.dart @@ -0,0 +1,94 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pyramid/features/call_ui/bubble_physics.dart'; + +/// Lässt die Simulation [seconds] lang laufen (60 Schritte pro Sekunde). +bool _run(BubbleSim sim, double seconds) { + var moving = true; + for (var i = 0; i < (seconds * 60).round(); i++) { + moving = sim.step(1 / 60); + } + return moving; +} + +void main() { + const phone = Size(390, 620); + const desktop = Size(1200, 700); + + test('zwei Bubbles drücken sich leicht aneinander und kommen zur Ruhe', () { + final sim = BubbleSim()..sync(['a', 'b'], {}, desktop); + final moving = _run(sim, 6); + expect(moving, isFalse, reason: 'Simulation kommt nicht zur Ruhe'); + + final a = sim.bodies['a']!, b = sim.bodies['b']!; + final dist = (a.pos - b.pos).distance; + // Sie berühren sich (überlappen leicht) statt frei zu schweben … + expect(dist, lessThan(a.r + b.r)); + // … liegen aber nicht ineinander. + expect(dist, greaterThan((a.r + b.r) * 0.8)); + // Querformat: nebeneinander statt übereinander + expect((a.pos.dy - b.pos.dy).abs(), lessThan((a.pos.dx - b.pos.dx).abs())); + }); + + test('wer spricht, wird größer', () { + final sim = BubbleSim()..sync(['a', 'b', 'c'], {}, phone); + _run(sim, 4); + final before = sim.bodies['b']!.r; + sim.sync(['a', 'b', 'c'], {'b'}, phone); + _run(sim, 4); + final b = sim.bodies['b']!.r; + expect(b, greaterThan(before * 1.1)); + expect(b, greaterThan(sim.bodies['a']!.r * 1.1)); + }); + + test('viele Teilnehmer bleiben im Bild (Handy hochkant)', () { + final ids = [for (var i = 0; i < 8; i++) 'p$i']; + final sim = BubbleSim()..sync(ids, {}, phone); + _run(sim, 8); + for (final b in sim.bodies.values) { + expect(b.pos.dx - b.r, greaterThan(-2), reason: '${b.id} links raus'); + expect(b.pos.dx + b.r, lessThan(phone.width + 2), reason: '${b.id} rechts raus'); + expect(b.pos.dy - b.r, greaterThan(-2), reason: '${b.id} oben raus'); + expect(b.pos.dy + b.r, lessThan(phone.height + 2), reason: '${b.id} unten raus'); + } + }); + + test('Gehende schrumpfen weg und verschwinden', () { + final sim = BubbleSim()..sync(['a', 'b'], {}, phone); + _run(sim, 3); + sim.sync(['a'], {}, phone); + _run(sim, 3); + expect(sim.bodies.keys, ['a']); + }); + + test('Berührungsfläche ist abgeflacht, freier Rand bleibt rund', () { + final sim = BubbleSim()..sync(['a', 'b'], {}, desktop); + _run(sim, 6); + final a = sim.bodies['a']!, b = sim.bodies['b']!; + final bounds = sim.clipPath('a').getBounds(); + final toB = (b.pos - a.pos); + // In Richtung des Nachbarn endet die Bubble vor ihrem Kreisrand … + if (toB.dx > 0) { + expect(bounds.right, lessThan(a.pos.dx + a.r - 1)); + // … auf der freien Seite reicht sie bis zum Kreisrand. + expect(bounds.left, closeTo(a.pos.dx - a.r, 1)); + } else { + expect(bounds.left, greaterThan(a.pos.dx - a.r + 1)); + expect(bounds.right, closeTo(a.pos.dx + a.r, 1)); + } + // Mittelpunkt liegt im Umriss, der Nachbar-Mittelpunkt nicht. + expect(sim.clipPath('a').contains(a.pos), isTrue); + expect(sim.clipPath('a').contains(b.pos), isFalse); + }); + + test('einzelne Bubble: rund, mittig', () { + final sim = BubbleSim()..sync(['solo'], {}, phone); + _run(sim, 4); + final s = sim.bodies['solo']!; + expect(s.pos.dx, closeTo(phone.width / 2, 2)); + expect(s.pos.dy, closeTo(phone.height / 2, 2)); + final bounds = sim.clipPath('solo').getBounds(); + expect(bounds.width, closeTo(s.r * 2, 1)); + }); +}