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
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user