Files
pyramid/lib/features/rooms/panel/rooms_list.dart
T
Bernd SteckmeisterandClaude Opus 5.5 c38e9c18b0 feat: Handy-Chatliste als Messenger-Kachel (Vorschau, Uhrzeit, Ungelesen) + deutsche Beschriftungen
- rooms_mobile_tile.dart: auf dem Handy grosser Avatar, Name + Uhrzeit der
  letzten Nachricht, Vorschau (gleiche Texte wie Push: Medien-Labels,
  Antwort-Zitat entfernt; "Du:" bzw. Absender in Gruppen), Ungelesen-Zaehler.
  PC behaelt die kompakte Liste, Voice-Kanaele ihre Darstellung.
- utils/date_labels.dart: chatListTime (heute Uhrzeit, Gestern, Wochentag,
  Datum; sicher an Zeitumstellungs-Tagen) + 5 Tests.
- Sichtbare englische Texte eingedeutscht (Chats durchsuchen,
  Direktnachrichten, Raeume, Nachricht..., Mitglieder, Suchen, ...).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 17:21:33 +02:00

657 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
part of '../rooms_panel.dart';
class _DraggableRoomItem extends StatefulWidget {
final Room room;
final int index;
final bool isActive;
final bool isInVoice;
final PyramidTheme pt;
final VoidCallback onTap;
const _DraggableRoomItem({
super.key,
required this.room,
required this.index,
required this.isActive,
this.isInVoice = false,
required this.pt,
required this.onTap,
});
@override
State<_DraggableRoomItem> createState() => _DraggableRoomItemState();
}
class _DraggableRoomItemState extends State<_DraggableRoomItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Row(children: [
ReorderableDragStartListener(
index: widget.index,
child: MouseRegion(
cursor: SystemMouseCursors.grab,
child: AnimatedOpacity(
opacity: _hovered ? 1.0 : 0.0,
duration: const Duration(milliseconds: 120),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Icon(Icons.drag_indicator_rounded,
size: 12, color: widget.pt.fgDim),
),
),
),
),
Expanded(
child: _RoomItem(
room: widget.room,
isActive: widget.isActive,
isInVoice: widget.isInVoice,
pt: widget.pt,
onTap: widget.onTap,
),
),
]),
);
}
}
class _RoomsList extends ConsumerWidget {
final List<Room> rooms;
final String filter;
final String? spaceId;
final Map<String, bool> collapsed;
final PyramidTheme pt;
final ValueChanged<String> onToggleSection;
const _RoomsList({
required this.rooms,
required this.filter,
required this.spaceId,
required this.collapsed,
required this.pt,
required this.onToggleSection,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final activeRoomId = ref.watch(activeRoomIdProvider);
final isDms = spaceId == 'dms';
final invites = rooms
.where((r) =>
r.membership == Membership.invite &&
isDms == r.isDirectChat)
.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)));
}
final sectionTitle = isDms ? 'Direktnachrichten' : 'Räume';
final sectionId = isDms ? 'dms' : 'rooms';
final isCollapsed = collapsed[sectionId] ?? false;
return ListView(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
children: [
if (invites.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 2),
child: Text('EINLADUNGEN',
style: TextStyle(
color: pt.accent,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6)),
),
...invites.map((r) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
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)
...filtered.map((room) {
final isVoice = _isVoiceRoom(room);
return _RoomItem(
room: room,
isActive: isVoice ? false : activeRoomId == room.id,
isInVoice:
isVoice && ref.read(activeVoiceRoomIdProvider) == room.id,
pt: pt,
// Voice-Räume treten dem Kanal bei (wie in der Space-Liste) –
// vorher öffnete der Klick hier nur die Chat-Ansicht, ein
// Standalone-Voice-Channel war per Klick gar nicht beitretbar.
onTap: () {
if (isVoice) {
_toggleVoiceChannel(ref, room);
} else {
ref.read(activeRoomIdProvider.notifier).state = room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
},
);
}),
],
);
}
}
/// 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).
Future<void> _toggleVoiceChannel(WidgetRef ref, Room room) async {
final call = ref.read(voiceChannelProvider);
final activeVoice = ref.read(activeVoiceRoomIdProvider);
if (call.isVoiceChannel && activeVoice == room.id) {
ref.read(activeVoiceRoomIdProvider.notifier).state = null;
await call.hangUp();
return;
}
if (call.isActive) await call.hangUp();
ref.read(activeVoiceRoomIdProvider.notifier).state = room.id;
await call.startCall(
roomName: room.id,
roomDisplayName: room.getLocalizedDisplayname(),
identity: room.client.userID ?? '',
audioOnly: true,
voiceChannel: true,
matrixClient: room.client,
matrixRoomId: room.id,
);
}
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;
final bool isInVoice;
final PyramidTheme pt;
final VoidCallback onTap;
const _RoomItem({
super.key,
required this.room,
required this.isActive,
this.isInVoice = false,
required this.pt,
required this.onTap,
});
@override
ConsumerState<_RoomItem> createState() => _RoomItemState();
}
class _RoomItemState extends ConsumerState<_RoomItem> {
bool _hovered = false;
Future<void> _leaveRoom() async {
final pt = widget.pt;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl),
side: BorderSide(color: pt.border)),
title: Text('Raum verlassen?',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
content: Text(
'"${widget.room.getLocalizedDisplayname()}" verlassen?',
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.danger,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Verlassen'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
final id = widget.room.id;
await widget.room.leave();
if (mounted && ref.read(activeRoomIdProvider) == id) {
ref.read(activeRoomIdProvider.notifier).state = null;
}
} catch (_) {}
}
Future<void> _leaveDm() async {
final pt = widget.pt;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl),
side: BorderSide(color: pt.border)),
title: Text('Chat verlassen?',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
content: Text(
'Unterhaltung mit "${widget.room.getLocalizedDisplayname()}" schließen?',
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.danger,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Verlassen'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
final id = widget.room.id;
await widget.room.leave();
if (mounted) {
if (ref.read(activeRoomIdProvider) == id) {
ref.read(activeRoomIdProvider.notifier).state = null;
}
}
} catch (_) {}
}
Future<void> _showRoomSettings() async {
await showDialog(
context: context,
builder: (_) => RoomSettingsDialog(room: widget.room),
);
}
Future<void> _inviteUser() async {
final pt = widget.pt;
final ctrl = TextEditingController();
final userId = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl),
side: BorderSide(color: pt.border)),
title: Text('Nutzer einladen',
style: TextStyle(
color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
content: TextField(
controller: ctrl,
autofocus: true,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
hintText: '@nutzer:server',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 12),
filled: true,
fillColor: pt.bg3,
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.border)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.border)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.accent)),
),
cursorColor: pt.accent,
onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
TextButton(
onPressed: () => Navigator.pop(ctx, ctrl.text.trim()),
child: Text('Einladen', style: TextStyle(color: pt.accent))),
],
),
);
ctrl.dispose();
if (userId == null || userId.isEmpty || !mounted) return;
try {
await widget.room.invite(userId);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Fehler: ${e.toString().split('\n').first}'),
backgroundColor: widget.pt.danger,
));
}
}
}
/// Touch-friendly action menu — opened via long-press (the hover menu is not
/// reachable on touch devices).
void _showContextMenu() {
final pt = widget.pt;
final room = widget.room;
final isDm = room.isDirectChat;
Widget action(IconData icon, String label, VoidCallback onTap,
{bool danger = false}) {
return ListTile(
leading: Icon(icon, size: 20, color: danger ? pt.danger : pt.fgMuted),
title: Text(label,
style: TextStyle(
color: danger ? pt.danger : pt.fg, fontSize: 14)),
onTap: () {
Navigator.pop(context);
onTap();
},
);
}
showModalBottomSheet(
context: context,
backgroundColor: pt.bg2,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(children: [
Expanded(
child: Text(room.getLocalizedDisplayname(),
style: TextStyle(
color: pt.fg,
fontSize: 15,
fontWeight: FontWeight.w700),
overflow: TextOverflow.ellipsis),
),
]),
),
Divider(height: 1, color: pt.border),
if (!isDm) ...[
action(Icons.person_add_outlined, 'Nutzer einladen', _inviteUser),
action(Icons.settings_rounded, 'Einstellungen', _showRoomSettings),
],
action(
Icons.exit_to_app_rounded,
isDm ? 'Chat verlassen' : 'Raum verlassen',
isDm ? _leaveDm : _leaveRoom,
danger: true,
),
const SizedBox(height: 8),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final room = widget.room;
final active = widget.isActive;
final inVoice = widget.isInVoice;
final name = room.getLocalizedDisplayname();
final unread = room.notificationCount;
final isDm = room.isDirectChat;
final isVoice = _isVoiceRoom(room);
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
final client = ref.watch(matrixClientProvider).valueOrNull;
// Handy: Messenger-Kachel mit Vorschau (rooms_mobile_tile.dart).
if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
return _MobileRoomTile(
room: room,
isActive: active,
pt: pt,
myUserId: client?.userID,
onTap: widget.onTap,
onLongPress: _showContextMenu,
);
}
Color textColor = (active || inVoice || unread > 0 || _hovered) ? pt.fg : pt.fgMuted;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
onLongPress: _showContextMenu,
child: Stack(
children: [
if (active)
Positioned(
left: 0,
top: 6,
bottom: 6,
child: Container(
width: 3,
decoration: BoxDecoration(
color: pt.accent,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(2),
bottomRight: Radius.circular(2),
),
),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
margin: const EdgeInsets.symmetric(vertical: 1),
padding: EdgeInsets.symmetric(
horizontal: pt.rowPadX, vertical: pt.rowPadY),
decoration: BoxDecoration(
color: active
? pt.accentSoft
: inVoice
? const Color(0xFF57F287).withAlpha(25)
: _hovered ? pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
if (isDm) ...[
_DmAvatar(room: room, pt: pt),
] else ...[
SizedBox(
width: 16,
height: 16,
child: Icon(
isVoice ? Icons.mic_rounded : _roomIcon(room),
size: 14,
color: isVoice
? (inVoice ? const Color(0xFF57F287) : _hovered ? const Color(0xFF57F287) : const Color(0xFF57F287).withAlpha(180))
: (active ? pt.accent : _hovered ? pt.fg : pt.fgDim),
),
),
],
const SizedBox(width: 8),
Expanded(
child: Text(
name,
style: TextStyle(
color: textColor,
fontSize: 14,
fontWeight: unread > 0 || active || inVoice
? FontWeight.w600
: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
if (isVoice && voiceParticipants.isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Text(
'${voiceParticipants.length}',
style: TextStyle(
color: const Color(0xFF57F287).withAlpha(200),
fontSize: 11,
fontWeight: FontWeight.w600),
),
),
if (!isVoice && _hovered) ...[
if (isDm)
_HoverAction(icon: Icons.close, pt: pt, onTap: _leaveDm)
else
_RoomMenuBtn(
pt: pt,
onInvite: _inviteUser,
onSettings: _showRoomSettings,
onLeave: _leaveRoom,
),
] else if (!isVoice && unread > 0) ...[
_UnreadBadge(
count: unread,
isMention: room.highlightCount > 0,
pt: pt),
],
],
),
// Voice participants (Discord-style named rows)
if (isVoice && voiceParticipants.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2, left: 24),
child: _VoiceParticipantList(
participants: voiceParticipants,
client: client,
pt: pt,
),
),
],
),
),
],
),
),
);
}
IconData _roomIcon(Room room) {
if (room.encrypted) return Icons.lock_outline_rounded;
return Icons.tag_rounded;
}
}