25ed765a03
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
264 lines
8.3 KiB
Dart
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];
|
|
}
|
|
}
|