Files
Bernd Steckmeister 25ed765a03 wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.)
als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen
Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 05:47:18 +02:00

264 lines
8.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/pyramid_logo.dart';
class MembersPanel extends ConsumerWidget {
final String roomId;
/// When set, a close button is shown in the header (used on mobile/overlay).
final VoidCallback? onClose;
/// Panel width. Defaults to 280 (desktop side panel).
final double width;
const MembersPanel({super.key, required this.roomId, this.onClose, this.width = 280});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
final client = ref.watch(matrixClientProvider).valueOrNull;
final room = client?.getRoomById(roomId);
final members = room?.getParticipants() ?? [];
// Show all members; presence info may not be available
final online = members.toList();
final offline = <User>[];
return Container(
width: width,
decoration: BoxDecoration(
color: pt.bg1,
border: Border(left: BorderSide(color: pt.border)),
),
child: Column(
children: [
Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: pt.border)),
),
child: Row(
children: [
Icon(Icons.group_rounded, size: 16, color: pt.fg),
const SizedBox(width: 8),
Text(
'Members',
style: TextStyle(
color: pt.fg,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 6),
Text(
'${members.length}',
style: TextStyle(color: pt.fgDim, fontSize: 14),
),
if (onClose != null) ...[
const Spacer(),
GestureDetector(
onTap: onClose,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(Icons.close_rounded, size: 20, color: pt.fgMuted),
),
),
],
],
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.all(8),
children: [
if (online.isNotEmpty) ...[
_SectionLabel('Online — ${online.length}', pt),
...online.map((m) => _MemberItem(member: m, pt: pt)),
],
if (offline.isNotEmpty) ...[
const SizedBox(height: 14),
_SectionLabel('Offline — ${offline.length}', pt),
...offline.map((m) => _MemberItem(
member: m,
pt: pt,
isOffline: true,
)),
],
if (members.isEmpty) ...[
// Show placeholder members when room has none loaded
_SectionLabel('Online — 0', pt),
_SectionLabel('', pt),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 24),
child: Text(
'No members loaded',
style: TextStyle(color: pt.fgDim, fontSize: 12),
),
),
),
],
],
),
),
],
),
);
}
}
class _SectionLabel extends StatelessWidget {
final String text;
final PyramidTheme pt;
const _SectionLabel(this.text, this.pt);
@override
Widget build(BuildContext context) {
if (text.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: Text(
text.toUpperCase(),
style: TextStyle(
color: pt.fgDim,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.04 * 11,
),
),
);
}
}
class _MemberItem extends StatefulWidget {
final User member;
final PyramidTheme pt;
final bool isOffline;
const _MemberItem({
required this.member,
required this.pt,
this.isOffline = false,
});
@override
State<_MemberItem> createState() => _MemberItemState();
}
class _MemberItemState extends State<_MemberItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final member = widget.member;
final name = member.calcDisplayname();
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
final color = _colorFromName(name);
final status = widget.isOffline ? 'offline' : _memberStatus(member);
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
decoration: BoxDecoration(
color: _hovered ? pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Row(
children: [
Opacity(
opacity: widget.isOffline ? 0.5 : 1,
child: Stack(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Center(
child: Text(
initial,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
),
if (!widget.isOffline)
Positioned(
bottom: -2,
right: -2,
child: PresenceDot(
status: status,
size: 9,
borderColor: pt.bgHover,
),
),
],
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
color: widget.isOffline ? pt.fgMuted : pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
}
String _memberStatus(User m) {
try {
// ignore: deprecated_member_use
final p = m.room.client.presences[m.id];
if (p == null) return 'offline';
// Veraltete "online"-Presence vom Server nicht blind übernehmen:
// nur online zeigen, wenn die letzte Aktivität frisch ist.
final last = p.lastActiveTimestamp;
final fresh = last != null &&
DateTime.now().difference(last) < const Duration(minutes: 5);
return switch (p.presence) {
PresenceType.online when p.currentlyActive == true || fresh => 'online',
PresenceType.online => 'offline',
PresenceType.unavailable => 'away',
_ => 'offline',
};
} catch (_) {
return 'offline';
}
}
Color _colorFromName(String name) {
const colors = [
Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFF10B981),
Color(0xFFF43F5E), Color(0xFF3B82F6), Color(0xFFF59E0B),
];
if (name.isEmpty) return colors[0];
return colors[name.codeUnitAt(0) % colors.length];
}
}