Files
pyramid/lib/features/rooms/panel/rooms_list.dart
T
Bernd SteckmeisterandClaude Opus 5.5 391ebf0ffa feat(ui): untere Leiste (Chats/Server/Du) als Option, Server-Uebersicht, Du-Tab, Feinschliff
- Einstellung 'Navigation: Seitenmenue | Untere Leiste' (Handy, Standard
  bleibt Seitenmenue): Tabs Chats/Server/Du, Server-Uebersicht als Karten,
  Du-Tab mit Profil + Einstellungen, Zurueck-Taste ueber die Tabs
- CallDock als eigenes Widget (Anrufleiste auch ueber der Tab-Leiste)
- Sprachkanaele am Handy als Kachel mit 'wer ist drin'
- Suchfeld mit Lupe, 'Kanaele durchsuchen' im Server, Filter-Chips am PC
  umbrechend, '#' nur bei Server-Kanaelen, englische Reste entfernt
- Anrufansicht merkt sich Bubbles/Kacheln, Bubbles mit leichtem Glanz
- UI-Tour: Gruppenanruf (echter zweiter LiveKit-Teilnehmer), Bubbles mit
  5/7 Personen, untere Leiste; Version 0.1.42

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 14:22:02 +02:00

742 lines
25 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,
),
),
]),
);
}
}
/// 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<Room> rooms;
final String filter;
final PyramidTheme pt;
const _RoomsList({
required this.rooms,
required this.filter,
required this.pt,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final activeRoomId = ref.watch(activeRoomIdProvider);
final channels = ref.watch(spaceChannelIdsProvider);
final chatFilter = ref.watch(_chatFilterProvider);
final query = filter.toLowerCase();
final invites =
rooms.where((r) => r.membership == Membership.invite).toList();
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 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, 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),
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),
],
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:
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;
}
},
);
}),
],
);
}
}
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)),
),
),
),
);
}
final chips = [
chip(_ChatFilter.all, 'Alle'),
chip(_ChatFilter.unread,
unreadCount > 0 ? 'Ungelesen · $unreadCount' : 'Ungelesen'),
chip(_ChatFilter.people, 'Personen'),
chip(_ChatFilter.groups, 'Gruppen'),
];
// Handy: wischbare Zeile; PC (schmale Spalte): umbrechen statt
// abschneiden – mit der Maus wischt niemand seitwärts.
if (!isMobileLayout(MediaQuery.sizeOf(context))) {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
child: Wrap(runSpacing: 6, children: chips),
);
}
return SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
children: chips,
),
);
}
}
/// 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).
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 _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: Sprachkanal als Kachel mit Teilnehmern (rooms_mobile_tile.dart).
if (isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
return _MobileVoiceTile(
room: room,
inVoice: inVoice,
participants: voiceParticipants,
client: client,
pt: pt,
onTap: widget.onTap,
onLongPress: _showContextMenu,
);
}
// 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 if (!isVoice &&
!ref
.watch(spaceChannelIdsProvider)
.contains(room.id)) ...[
_RoomTileAvatar(room: room, pt: pt, size: 22),
] 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;
}
}