refactor: Gott-Datei rooms_panel.dart in 8 Dateien aufgeteilt

Gleiche Technik wie bei settings_modal.dart/message_group.dart: reine
Verschiebung per Dart part/part-of, keine Logikänderung. rooms_panel.dart
schrumpft von 2555 auf 31 Zeilen (nur noch Bibliothekskopf). Verifiziert
per automatisiertem Blockvergleich gegen das Original und flutter analyze.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bernd Steckmeister
2026-07-03 11:00:22 +02:00
parent 5bf45a9366
commit a863fa3b9a
12 changed files with 2561 additions and 2535 deletions
+2
View File
@@ -1163,3 +1163,5 @@
2026-07-03 10:56 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 10:58 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 10:58 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 11:00 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 11:00 [Edit] /home/steggi/pyramid/PROGRESS.md
+39
View File
@@ -13,6 +13,45 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
---
## 2026-07-03 M2: Gott-Datei `rooms_panel.dart` aufgeteilt (8 Dateien)
**Erledigt:** Gleiche `part`/`part of`-Technik ein drittes Mal angewandt:
`features/rooms/rooms_panel.dart` (2555 Zeilen, 37 Klassen/Enums + 4 lose
Top-Level-Helfer) aufgeteilt in 8 Dateien unter `features/rooms/panel/`:
`rooms_panel_core.dart` (`RoomsPanel`, `_RoomsPanelState` + die
Top-Level-Helfer `_kVoiceRoomType`/`_kSpaceBannerType`/`_isVoiceRoom`/
`_compareSpaceChildren`, die von mehreren anderen Teilen genutzt werden
deshalb bewusst NICHT in ein eigenes „shared"-File, sondern beim Hauptwidget
belassen, wo sie ursprünglich standen), `rooms_header.dart` (`_Header`,
`_MenuRow`, `_NotificationsButton`, `_FilterInput`, `_SpaceMenuItem`),
`rooms_space_invite.dart` (`_SpaceInviteView`+State), `rooms_space_list.dart`
(`_SpaceRoomsList`+State, `_JoinableRoom`+Item+State, `_CategoryHeader`+State),
`rooms_list.dart` (`_RoomsList`, `_SectionHeader`+State, `_RoomItem`+State,
`_DraggableRoomItem`+State), `rooms_voice.dart` (`_VoiceParticipantList`,
`_Initials`, `_VoiceBarBtn`+State), `rooms_invite_item.dart`
(`_InviteItem`+State), `rooms_shared.dart` (`_DmAvatar`+State,
`_InitialCircle`, `_RoomMenuBtn`+State, `_HoverAction`+State, `_UnreadBadge`).
`rooms_panel.dart` selbst: nur noch Bibliothekskopf (Imports + 8
`part`-Direktiven, 31 Zeilen).
Verifikation wie bei den beiden vorigen Umbauten: automatisierte
Klammer-Zählung für Blockgrenzen, alle 37 Blöcke + 4 Helfer wortwörtlich in
den neuen Dateien wiedergefunden, Anzahl `class`/`enum` (37) und
`Widget build(`-Methoden (22) vorher/nachher identisch, `flutter analyze`
unverändert bei 1 bekanntem Hinweis. `dart format` wieder bewusst nicht
gelaufen.
**Offen/Nächster Schritt:** UNGETESTET (Pi). Nächster Windows-Lauf: Raumliste
prüfen Space wechseln, Raum beitreten/verlassen, Voice-Channel-Teilnehmerliste
in der Raumliste, Einladung annehmen/ablehnen, Drag&Drop zum Umsortieren,
Ungelesen-Badge, DM-Avatare. Danach `space_admin_dialog.dart` (2278 Zeilen)
oder `chat_view.dart` (2116 Zeilen) nach demselben Muster damit wären alle
in der Ist-Analyse gefundenen Gott-Dateien abgearbeitet.
**Stolperfallen:** Keine neuen.
---
## 2026-07-03 M2: Gott-Datei `message_group.dart` aufgeteilt (7 Dateien)
**Erledigt:** Gleiche Technik wie beim `settings_modal.dart`-Umbau direkt
+5 -3
View File
@@ -82,9 +82,11 @@ lassen, ohne dass andere Module angefasst werden müssen.
`part`-Dateien unter `widgets/settings/` aufgeteilt, siehe PROGRESS.md
2026-07-03 „Settings-Datei aufgeteilt"; `features/chat/message_group.dart`
(2775 Zeilen) ebenso in 7 `part`-Dateien unter `features/chat/message/`
aufgeteilt (PROGRESS.md 2026-07-03 „message_group.dart aufgeteilt")
restliche Gott-Dateien `rooms_panel.dart`, `space_admin_dialog.dart`,
`chat_view.dart` aus der Ist-Analyse noch offen)
aufgeteilt (PROGRESS.md 2026-07-03 „message_group.dart aufgeteilt");
`features/rooms/rooms_panel.dart` (2555 Zeilen) ebenso in 8
`part`-Dateien unter `features/rooms/panel/` aufgeteilt (PROGRESS.md
2026-07-03 „rooms_panel.dart aufgeteilt") restliche Gott-Dateien
`space_admin_dialog.dart`, `chat_view.dart` aus der Ist-Analyse noch offen)
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
## M3 Calls: Logik, UI & Audio (Discord als Referenz)
+501
View File
@@ -0,0 +1,501 @@
part of '../rooms_panel.dart';
enum _SpaceMenuItem {
newDm, createGroup, createRoom, joinRoom, discoverRooms,
editSpace, adminSpace, leaveSpace,
}
class _Header extends ConsumerWidget {
final PyramidTheme pt;
final String? spaceId;
const _Header({required this.pt, required this.spaceId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isRealSpace = spaceId != null && spaceId != 'dms' && spaceId != 'rooms';
final size = MediaQuery.sizeOf(context);
final isMobile = size.width < 900 || size.height < 500;
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
Room? activeSpace;
if (isRealSpace) {
try {
activeSpace = spaces.firstWhere((s) => s.id == spaceId);
} catch (_) {}
}
final client = ref.watch(matrixClientProvider).valueOrNull;
final isAdmin =
activeSpace != null && activeSpace.ownPowerLevel >= 50;
final name = isRealSpace
? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
: (spaceId == 'dms' ? 'Chats' : 'Rooms');
void handleMenu(_SpaceMenuItem item) {
switch (item) {
case _SpaceMenuItem.newDm:
showDialog(context: context, builder: (_) => const NewDmDialog());
case _SpaceMenuItem.createGroup:
showDialog(
context: context, builder: (_) => const CreateJoinDialog());
case _SpaceMenuItem.createRoom:
showDialog(
context: context, builder: (_) => const CreateJoinDialog());
case _SpaceMenuItem.joinRoom:
showDialog(
context: context,
builder: (_) =>
const CreateJoinDialog(initialTab: CreateJoinTab.join));
case _SpaceMenuItem.discoverRooms:
showDialog(
context: context,
builder: (_) =>
const CreateJoinDialog(initialTab: CreateJoinTab.discover));
case _SpaceMenuItem.editSpace:
if (activeSpace != null) {
showDialog(
context: context,
builder: (_) => SpaceEditDialog(space: activeSpace!));
}
case _SpaceMenuItem.adminSpace:
if (activeSpace != null) {
showDialog(
context: context,
builder: (_) => SpaceAdminDialog(space: activeSpace!));
}
case _SpaceMenuItem.leaveSpace:
if (activeSpace != null) {
final space = activeSpace;
showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl),
side: BorderSide(color: pt.border)),
title: Text('Space verlassen?',
style: TextStyle(
color: pt.fg,
fontSize: 16,
fontWeight: FontWeight.w700)),
content: Text(
'Du verlässt "${space.getLocalizedDisplayname()}".',
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'),
),
],
),
).then((confirmed) {
if (confirmed == true) {
space.leave().then((_) {
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
});
}
});
}
}
}
List<PopupMenuEntry<_SpaceMenuItem>> buildMenuItems() {
if (spaceId == 'dms' || spaceId == null) {
return [
PopupMenuItem(
value: _SpaceMenuItem.newDm,
child: _MenuRow(
Icons.chat_bubble_outline_rounded, 'Neue Unterhaltung', pt)),
PopupMenuItem(
value: _SpaceMenuItem.createGroup,
child: _MenuRow(Icons.group_add_rounded, 'Gruppe erstellen', pt)),
];
} else if (spaceId == 'rooms') {
return [
PopupMenuItem(
value: _SpaceMenuItem.createRoom,
child: _MenuRow(Icons.add_rounded, 'Raum erstellen', pt)),
PopupMenuItem(
value: _SpaceMenuItem.joinRoom,
child: _MenuRow(Icons.login_rounded, 'Beitreten', pt)),
PopupMenuItem(
value: _SpaceMenuItem.discoverRooms,
child: _MenuRow(Icons.explore_rounded, 'Entdecken', pt)),
];
} else {
return [
if (isAdmin) ...[
PopupMenuItem(
value: _SpaceMenuItem.adminSpace,
child: _MenuRow(
Icons.admin_panel_settings_rounded, 'Space verwalten', pt)),
PopupMenuItem(
value: _SpaceMenuItem.editSpace,
child: _MenuRow(Icons.edit_outlined, 'Name/Beschreibung', pt)),
const PopupMenuDivider(),
],
PopupMenuItem(
value: _SpaceMenuItem.leaveSpace,
child: _MenuRow(Icons.exit_to_app_rounded, 'Space verlassen', pt,
danger: true)),
];
}
}
// Check for banner — space banner for real spaces, server banner otherwise.
final bannerOverrides = ref.watch(spaceBannerOverrideProvider);
final serverBanners = ref.watch(serverBannerProvider).valueOrNull ?? {};
String? bannerUrl;
if (isRealSpace && activeSpace != null) {
bannerUrl = bannerOverrides.containsKey(activeSpace.id)
? bannerOverrides[activeSpace.id]
: activeSpace.getState(_kSpaceBannerType)?.content['url'] as String?;
} else if (client != null) {
bannerUrl = serverBanners[client.homeserver.toString()];
}
final hasBanner = bannerUrl != null && client != null;
Widget controlsRow = Row(
children: [
if (isMobile)
PyrIconBtn(
icon: const Icon(Icons.menu_rounded),
tooltip: 'Spaces',
onPressed: () => Scaffold.of(context).openDrawer(),
)
else ...[
PyrIconBtn(
icon: const Icon(Icons.menu_rounded),
tooltip: 'Toggle sidebar',
onPressed: () =>
ref.read(railCollapsedProvider.notifier).update((s) => !s),
),
const SizedBox(width: 4),
],
// Space avatar (only when no banner)
if (isRealSpace && !hasBanner && client != null && activeSpace != null) ...[
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: pt.accent.withAlpha(40),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: activeSpace.avatar != null
? MxcAvatar(
mxcUri: activeSpace.avatar,
client: client,
size: 28,
borderRadius: BorderRadius.circular(8),
placeholder: (_) => Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: pt.accent,
fontSize: 13,
fontWeight: FontWeight.w700),
),
),
)
: Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: pt.accent,
fontSize: 13,
fontWeight: FontWeight.w700),
),
),
),
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
name,
style: TextStyle(
color: hasBanner ? Colors.white : pt.fg,
fontSize: 15,
fontWeight: isRealSpace ? FontWeight.w700 : FontWeight.w600,
shadows: hasBanner
? [const Shadow(blurRadius: 4, color: Colors.black45)]
: null,
),
overflow: TextOverflow.ellipsis,
),
),
if (isRealSpace && isAdmin)
PyrIconBtn(
icon: Icon(Icons.admin_panel_settings_rounded,
color: hasBanner ? Colors.white70 : null),
tooltip: 'Space verwalten',
onPressed: () => handleMenu(_SpaceMenuItem.adminSpace),
),
if (!isRealSpace)
_NotificationsButton(pt: pt, hasBanner: hasBanner),
PopupMenuButton<_SpaceMenuItem>(
tooltip: 'Erstellen',
color: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: pt.border)),
itemBuilder: (_) => buildMenuItems(),
onSelected: handleMenu,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(Icons.add_rounded,
color: hasBanner ? Colors.white70 : pt.fgDim, size: 18),
),
),
],
);
if (hasBanner) {
return SizedBox(
height: 110,
child: Stack(
fit: StackFit.expand,
children: [
// Banner image
MxcImage(
mxcUri: bannerUrl,
client: client,
fit: BoxFit.cover,
placeholder: Container(color: pt.bg0),
),
// Gradient overlay — bottom-heavy so text is readable
DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.0, 0.4, 1.0],
colors: [
Color(0x00000000),
Color(0x55000000),
Color(0xCC000000),
],
),
),
),
// Controls at the bottom
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: controlsRow,
),
),
// Bottom border
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(height: 1, color: pt.border.withAlpha(80)),
),
],
),
);
}
return Container(
height: isRealSpace ? 56 : 52,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: isRealSpace ? pt.bg0 : pt.bg1,
border: Border(bottom: BorderSide(color: pt.border)),
),
child: controlsRow,
);
}
}
class _MenuRow extends StatelessWidget {
final IconData icon;
final String label;
final PyramidTheme pt;
final bool danger;
const _MenuRow(this.icon, this.label, this.pt, {this.danger = false});
@override
Widget build(BuildContext context) => Row(children: [
Icon(icon, size: 14, color: danger ? pt.danger : pt.fgMuted),
const SizedBox(width: 8),
Text(label,
style: TextStyle(
color: danger ? pt.danger : pt.fg, fontSize: 13)),
]);
}
class _NotificationsButton extends ConsumerWidget {
final PyramidTheme pt;
final bool hasBanner;
const _NotificationsButton({required this.pt, required this.hasBanner});
@override
Widget build(BuildContext context, WidgetRef ref) {
final rooms = ref.watch(roomListProvider).valueOrNull ?? [];
final unread = rooms
.where((r) =>
r.membership == Membership.join &&
(r.notificationCount > 0 || r.highlightCount > 0))
.toList()
..sort((a, b) {
final hl = b.highlightCount.compareTo(a.highlightCount);
if (hl != 0) return hl;
return b.notificationCount.compareTo(a.notificationCount);
});
return PopupMenuButton<String>(
tooltip: 'Benachrichtigungen',
color: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: pt.border)),
offset: const Offset(0, 36),
onSelected: (roomId) {
ref.read(activeRoomIdProvider.notifier).state = roomId;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
},
itemBuilder: (_) {
if (unread.isEmpty) {
return [
PopupMenuItem<String>(
enabled: false,
child: Text('Keine neuen Benachrichtigungen',
style: TextStyle(color: pt.fgDim, fontSize: 13)),
),
];
}
return unread
.map((r) => PopupMenuItem<String>(
value: r.id,
child: Row(
children: [
Icon(
r.highlightCount > 0
? Icons.alternate_email_rounded
: (r.isDirectChat
? Icons.chat_bubble_outline_rounded
: Icons.tag_rounded),
size: 14,
color:
r.highlightCount > 0 ? pt.danger : pt.fgMuted,
),
const SizedBox(width: 8),
Expanded(
child: Text(
r.getLocalizedDisplayname(),
style: TextStyle(color: pt.fg, fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: r.highlightCount > 0 ? pt.danger : pt.accent,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${r.notificationCount > 0 ? r.notificationCount : r.highlightCount}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700),
),
),
],
),
))
.toList();
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Stack(
clipBehavior: Clip.none,
children: [
Icon(Icons.notifications_none_rounded,
size: 18, color: hasBanner ? Colors.white70 : pt.fgDim),
if (unread.isNotEmpty)
Positioned(
top: -1,
right: -1,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: pt.danger,
shape: BoxShape.circle,
border: Border.all(color: pt.bg1, width: 1.5),
),
),
),
],
),
),
);
}
}
class _FilterInput extends StatelessWidget {
final TextEditingController controller;
final PyramidTheme pt;
final ValueChanged<String> onChanged;
const _FilterInput(
{required this.controller, required this.pt, required this.onChanged});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Stack(
alignment: Alignment.centerLeft,
children: [
Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(Icons.search_rounded, size: 14, color: pt.fgDim),
),
TextField(
controller: controller,
onChanged: onChanged,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
hintText: 'Filter rooms',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7),
filled: true,
fillColor: pt.bg2,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: const BorderSide(color: Colors.transparent)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: BorderSide(color: pt.accent, width: 1)),
isDense: true,
),
cursorColor: pt.accent,
),
],
),
);
}
}
@@ -0,0 +1,113 @@
part of '../rooms_panel.dart';
class _InviteItem extends ConsumerStatefulWidget {
final Room room;
final PyramidTheme pt;
const _InviteItem({required this.room, required this.pt});
@override
ConsumerState<_InviteItem> createState() => _InviteItemState();
}
class _InviteItemState extends ConsumerState<_InviteItem> {
bool _accepting = false;
bool _declining = false;
Future<void> _accept() async {
setState(() => _accepting = true);
try {
await widget.room.join();
if (mounted) {
ref.read(activeRoomIdProvider.notifier).state = widget.room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
} catch (_) {
if (mounted) setState(() => _accepting = false);
}
}
Future<void> _decline() async {
setState(() => _declining = true);
try {
await widget.room.leave();
} catch (_) {
if (mounted) setState(() => _declining = false);
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final name = widget.room.getLocalizedDisplayname();
final isDm = widget.room.isDirectChat;
return Container(
margin: const EdgeInsets.symmetric(vertical: 1),
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
decoration: BoxDecoration(
color: pt.accentSoft,
borderRadius: BorderRadius.circular(pt.rSm),
border: Border.all(color: pt.accent.withAlpha(50)),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(isDm ? Icons.person_rounded : Icons.tag_rounded,
size: 12, color: pt.accent),
const SizedBox(width: 6),
Expanded(
child: Text(name,
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
),
]),
const SizedBox(height: 6),
Row(children: [
Expanded(
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: pt.danger,
side: BorderSide(color: pt.danger.withAlpha(80)),
padding: const EdgeInsets.symmetric(vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6)),
),
onPressed: (_declining || _accepting) ? null : _decline,
child: _declining
? const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator.adaptive(strokeWidth: 1.5))
: const Text('Ablehnen', style: TextStyle(fontSize: 12)),
),
),
const SizedBox(width: 6),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent,
foregroundColor: pt.accentFg,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6)),
),
onPressed: (_accepting || _declining) ? null : _accept,
child: _accepting
? const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator.adaptive(strokeWidth: 1.5))
: const Text('Annehmen', style: TextStyle(fontSize: 12)),
),
),
]),
]),
);
}
}
+608
View File
@@ -0,0 +1,608 @@
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('No rooms', style: TextStyle(color: pt.fgDim, fontSize: 13)));
}
final sectionTitle = isDms ? 'Direct Messages' : 'Rooms';
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) => _RoomItem(
room: room,
isActive: activeRoomId == room.id,
pt: pt,
onTap: () {
ref.read(activeRoomIdProvider.notifier).state = room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
},
)),
],
);
}
}
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;
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;
}
}
@@ -0,0 +1,146 @@
part of '../rooms_panel.dart';
const _kVoiceRoomType = 'io.pyramid.voice_channel';
const _kSpaceBannerType = 'io.pyramid.space.banner';
bool _isVoiceRoom(Room room) =>
room.getState(EventTypes.RoomCreate)?.content['type'] == _kVoiceRoomType;
int _compareSpaceChildren(SpaceChild a, SpaceChild b) {
final ao = a.order;
final bo = b.order;
if (ao.isEmpty && bo.isEmpty) return 0;
if (ao.isEmpty) return 1;
if (bo.isEmpty) return -1;
return ao.compareTo(bo);
}
class RoomsPanel extends ConsumerStatefulWidget {
const RoomsPanel({super.key});
@override
ConsumerState<RoomsPanel> createState() => _RoomsPanelState();
}
class _RoomsPanelState extends ConsumerState<RoomsPanel> {
final _filterCtrl = TextEditingController();
String _filter = '';
final Map<String, bool> _collapsed = {};
@override
void dispose() {
_filterCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final roomsAsync = ref.watch(roomListProvider);
final activeSpaceId = ref.watch(activeSpaceIdProvider);
final call = ref.watch(callStateProvider);
final voip = ref.watch(voipStateProvider);
final isRealSpace = activeSpaceId != null &&
activeSpaceId != 'dms' &&
activeSpaceId != 'rooms';
final client = ref.watch(matrixClientProvider).valueOrNull;
final forceJoined = ref.watch(forceJoinedSpacesProvider);
final activeSpaceRoom =
isRealSpace ? client?.getRoomById(activeSpaceId) : null;
final isInvitedSpace = activeSpaceRoom?.membership == Membership.invite &&
!forceJoined.contains(activeSpaceId);
return Container(
color: pt.bg1,
child: Column(
children: [
_Header(pt: pt, spaceId: activeSpaceId),
if (!isInvitedSpace)
_FilterInput(
controller: _filterCtrl,
pt: pt,
onChanged: (v) => setState(() => _filter = v),
),
Expanded(
child: isInvitedSpace
? _SpaceInviteView(space: activeSpaceRoom!, pt: pt)
: isRealSpace
? _SpaceRoomsList(
spaceId: activeSpaceId,
filter: _filter,
collapsed: _collapsed,
pt: pt,
onToggleSection: (id) => setState(() {
_collapsed[id] = !(_collapsed[id] ?? false);
}),
)
: roomsAsync.when(
loading: () => Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: pt.accent),
),
),
error: (e, _) => Center(
child: Text('Error',
style: TextStyle(color: pt.fgDim, fontSize: 12)),
),
data: (rooms) => _RoomsList(
rooms: rooms,
filter: _filter,
spaceId: activeSpaceId,
collapsed: _collapsed,
pt: pt,
onToggleSection: (id) => setState(() {
_collapsed[id] = !(_collapsed[id] ?? false);
}),
),
),
),
// Call-Dock: immer ÜBER dem Konto-Balken, animiert ein-/ausblenden.
// Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und
// Konto-Balken nebeneinander statt übereinander.
Builder(builder: (context) {
final hasVoipCall = voip.currentCall != null &&
voip.currentCall!.state != CallState.kEnded;
final hasCallDock = call.isActive || hasVoipCall;
final isLandscapeCompact =
MediaQuery.sizeOf(context).height < 500;
final callDock = AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (call.isActive) MiniCallWidget(call: call),
if (hasVoipCall) MiniCallWidget(call: voip),
],
),
);
if (isLandscapeCompact && hasCallDock) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: callDock),
const Expanded(child: UserPanel()),
],
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [callDock, const UserPanel()],
);
}),
],
),
);
}
}
+279
View File
@@ -0,0 +1,279 @@
part of '../rooms_panel.dart';
class _DmAvatar extends ConsumerStatefulWidget {
final Room room;
final PyramidTheme pt;
const _DmAvatar({required this.room, required this.pt});
@override
ConsumerState<_DmAvatar> createState() => _DmAvatarState();
}
class _DmAvatarState extends ConsumerState<_DmAvatar> {
CachedPresence? _presence;
StreamSubscription<CachedPresence>? _sub;
@override
void initState() {
super.initState();
_loadPresence();
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
Future<void> _loadPresence() async {
final client = ref.read(matrixClientProvider).valueOrNull;
if (client == null) return;
final userId = widget.room.directChatMatrixID;
if (userId == null) return;
try {
final p = await client.fetchCurrentPresence(userId);
if (mounted) setState(() => _presence = p);
} catch (_) {}
_sub = client.onPresenceChanged.stream
.where((p) => p.userid == userId)
.listen((p) {
if (mounted) setState(() => _presence = p);
});
}
Color? _dotColor() {
final p = _presence;
if (p == null) return null;
// Server-Presence kann veraltet sein (z.B. nie als offline gemeldet).
// Nur als online werten, wenn die letzte Aktivität wirklich frisch ist.
final last = p.lastActiveTimestamp;
final fresh = last != null &&
DateTime.now().difference(last) < const Duration(minutes: 5);
if (p.presence == PresenceType.online &&
(p.currentlyActive == true || fresh)) {
return PyramidColors.online;
}
if (p.presence == PresenceType.unavailable) return PyramidColors.away;
return null;
}
@override
Widget build(BuildContext context) {
final name = widget.room.getLocalizedDisplayname();
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
final color = _colorFromName(name);
final client = ref.watch(matrixClientProvider).valueOrNull;
Widget avatar;
if (client != null && widget.room.avatar != null) {
avatar = MxcAvatar(
mxcUri: widget.room.avatar,
client: client,
size: 22,
borderRadius: BorderRadius.circular(11),
placeholder: (_) =>
_InitialCircle(initial: initial, color: color, size: 22),
);
} else {
avatar = _InitialCircle(initial: initial, color: color, size: 22);
}
final dot = _dotColor();
if (dot == null) return avatar;
return Stack(clipBehavior: Clip.none, children: [
avatar,
Positioned(
right: -2,
bottom: -2,
child: Container(
width: 9,
height: 9,
decoration: BoxDecoration(
color: dot,
shape: BoxShape.circle,
border: Border.all(color: widget.pt.bg1, width: 1.5),
),
),
),
]);
}
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];
}
}
class _InitialCircle extends StatelessWidget {
final String initial;
final Color color;
final double size;
const _InitialCircle(
{required this.initial, required this.color, required this.size});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
child: Center(
child: Text(initial,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600))),
);
}
}
class _RoomMenuBtn extends StatefulWidget {
final PyramidTheme pt;
final VoidCallback onInvite;
final VoidCallback onSettings;
final VoidCallback onLeave;
const _RoomMenuBtn({required this.pt, required this.onInvite, required this.onSettings, required this.onLeave});
@override
State<_RoomMenuBtn> createState() => _RoomMenuBtnState();
}
class _RoomMenuBtnState extends State<_RoomMenuBtn> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
color: widget.pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: widget.pt.border)),
offset: const Offset(-100, 24),
tooltip: '',
onSelected: (v) {
if (v == 'invite') widget.onInvite();
if (v == 'settings') widget.onSettings();
if (v == 'leave') widget.onLeave();
},
itemBuilder: (_) => [
PopupMenuItem(
value: 'invite',
height: 36,
child: Row(children: [
Icon(Icons.person_add_outlined, size: 14, color: widget.pt.fgMuted),
const SizedBox(width: 8),
Text('Nutzer einladen', style: TextStyle(color: widget.pt.fg, fontSize: 13)),
]),
),
PopupMenuItem(
value: 'settings',
height: 36,
child: Row(children: [
Icon(Icons.settings_rounded, size: 14, color: widget.pt.fgMuted),
const SizedBox(width: 8),
Text('Einstellungen', style: TextStyle(color: widget.pt.fg, fontSize: 13)),
]),
),
PopupMenuDivider(height: 1),
PopupMenuItem(
value: 'leave',
height: 36,
child: Row(children: [
Icon(Icons.exit_to_app_rounded, size: 14, color: widget.pt.danger),
const SizedBox(width: 8),
Text('Raum verlassen', style: TextStyle(color: widget.pt.danger, fontSize: 13)),
]),
),
],
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: 20,
height: 20,
decoration: BoxDecoration(
color: _hovered ? widget.pt.bgActive : Colors.transparent,
borderRadius: BorderRadius.circular(4),
),
child: Center(
child: Icon(Icons.more_horiz_rounded,
size: 12,
color: _hovered ? widget.pt.fg : widget.pt.fgDim)),
),
),
);
}
}
class _HoverAction extends StatefulWidget {
final IconData icon;
final PyramidTheme pt;
final VoidCallback? onTap;
const _HoverAction({required this.icon, required this.pt, this.onTap});
@override
State<_HoverAction> createState() => _HoverActionState();
}
class _HoverActionState extends State<_HoverAction> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onTap,
child: MouseRegion(
cursor: widget.onTap != null
? SystemMouseCursors.click
: MouseCursor.defer,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: 20,
height: 20,
decoration: BoxDecoration(
color: _hovered ? widget.pt.bgActive : Colors.transparent,
borderRadius: BorderRadius.circular(4),
),
child: Center(
child: Icon(widget.icon,
size: 12,
color: _hovered ? widget.pt.fg : widget.pt.fgDim)),
),
),
);
}
}
class _UnreadBadge extends StatelessWidget {
final int count;
final bool isMention;
final PyramidTheme pt;
const _UnreadBadge(
{required this.count, required this.isMention, required this.pt});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
decoration: BoxDecoration(
color: isMention ? pt.danger : pt.accent,
borderRadius: BorderRadius.circular(999),
),
child: Center(
child: Text(count > 99 ? '99+' : '$count',
style: TextStyle(
color: isMention ? Colors.white : pt.accentFg,
fontSize: 10,
fontWeight: FontWeight.w700))),
);
}
}
@@ -0,0 +1,159 @@
part of '../rooms_panel.dart';
class _SpaceInviteView extends ConsumerStatefulWidget {
final Room space;
final PyramidTheme pt;
const _SpaceInviteView({required this.space, required this.pt});
@override
ConsumerState<_SpaceInviteView> createState() => _SpaceInviteViewState();
}
class _SpaceInviteViewState extends ConsumerState<_SpaceInviteView> {
bool _accepting = false;
bool _declining = false;
Future<void> _accept() async {
setState(() => _accepting = true);
final client = widget.space.client;
final id = widget.space.id;
try {
await client.joinRoomById(id).timeout(const Duration(seconds: 20));
// Server confirmed the join. Switch to the channel list optimistically —
// the local sync membership may lag behind, so don't wait for it.
ref.read(forceJoinedSpacesProvider.notifier).add(id);
if (mounted) setState(() => _accepting = false);
} catch (e) {
if (mounted) {
setState(() => _accepting = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'Beitritt fehlgeschlagen: ${e.toString().split('\n').first}'),
backgroundColor: widget.pt.danger,
));
}
}
}
Future<void> _decline() async {
setState(() => _declining = true);
try {
await widget.space.leave();
if (mounted) {
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
}
} catch (_) {
if (mounted) setState(() => _declining = false);
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final client = ref.watch(matrixClientProvider).valueOrNull;
final name = widget.space.getLocalizedDisplayname();
final busy = _accepting || _declining;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: pt.accent.withAlpha(40),
borderRadius: BorderRadius.circular(20),
),
child: client != null && widget.space.avatar != null
? ClipRRect(
borderRadius: BorderRadius.circular(20),
child: MxcAvatar(
mxcUri: widget.space.avatar,
client: client,
size: 72,
borderRadius: BorderRadius.circular(20),
placeholder: (_) => Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: pt.accent,
fontSize: 28,
fontWeight: FontWeight.w700),
),
),
),
)
: Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: pt.accent,
fontSize: 28,
fontWeight: FontWeight.w700),
),
),
),
const SizedBox(height: 16),
Text('Einladung zum Space',
style: TextStyle(color: pt.fgDim, fontSize: 13)),
const SizedBox(height: 4),
Text(
name,
textAlign: TextAlign.center,
style: TextStyle(
color: pt.fg, fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: pt.danger,
side: BorderSide(color: pt.danger.withAlpha(100)),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
onPressed: busy ? null : _decline,
child: _declining
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2))
: const Text('Ablehnen'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent,
foregroundColor: pt.accentFg,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
onPressed: busy ? null : _accept,
child: _accepting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2))
: const Text('Beitreten'),
),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,569 @@
part of '../rooms_panel.dart';
class _SpaceRoomsList extends ConsumerStatefulWidget {
final String spaceId;
final String filter;
final Map<String, bool> collapsed;
final PyramidTheme pt;
final ValueChanged<String> onToggleSection;
const _SpaceRoomsList({
required this.spaceId,
required this.filter,
required this.collapsed,
required this.pt,
required this.onToggleSection,
});
@override
ConsumerState<_SpaceRoomsList> createState() => _SpaceRoomsListState();
}
class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
// Local order override after drag-and-drop (room IDs in order per category key)
final Map<String, List<String>> _localOrder = {};
// Joinable channels discovered via the space hierarchy (not yet a member).
List<_JoinableRoom> _joinable = [];
final Set<String> _joining = {};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final client = ref.read(matrixClientProvider).valueOrNull;
if (client != null) _loadHierarchy(client);
});
}
@override
void didUpdateWidget(covariant _SpaceRoomsList old) {
super.didUpdateWidget(old);
if (old.spaceId != widget.spaceId) {
setState(() => _joinable = []);
final client = ref.read(matrixClientProvider).valueOrNull;
if (client != null) _loadHierarchy(client);
}
}
/// Queries the server-side space hierarchy and keeps only rooms the user is
/// not yet a member of — these are the channels they can still join.
Future<void> _loadHierarchy(Client client) async {
try {
final resp = await client.getSpaceHierarchy(
widget.spaceId,
maxDepth: 3,
suggestedOnly: false,
);
final joinable = <_JoinableRoom>[];
for (final chunk in resp.rooms) {
if (chunk.roomId == widget.spaceId) continue;
if (chunk.roomType == 'm.space') continue; // skip sub-spaces
// Keep every channel the hierarchy reports. The build method filters out
// the ones already rendered in the joined sections — relying on local
// membership here is unreliable because Continuwuity's sync lags behind.
joinable.add(_JoinableRoom(
roomId: chunk.roomId,
name: chunk.name ?? chunk.canonicalAlias ?? chunk.roomId,
isVoice: chunk.roomType == _kVoiceRoomType,
memberCount: chunk.numJoinedMembers,
));
}
if (mounted) setState(() => _joinable = joinable);
} catch (_) {
// Hierarchy endpoint unavailable / no permission — silently ignore.
}
}
Future<void> _joinAndEnter(Client client, _JoinableRoom jr) async {
setState(() => _joining.add(jr.roomId));
try {
await client.joinRoomById(jr.roomId).timeout(const Duration(seconds: 20));
// Give the sync a brief chance to deliver the room object, but don't block
// on it — the local membership may lag behind the server.
Room? room = client.getRoomById(jr.roomId);
if (room == null) {
try {
await client.onSync.stream
.firstWhere((_) => client.getRoomById(jr.roomId) != null)
.timeout(const Duration(seconds: 4));
} catch (_) {}
room = client.getRoomById(jr.roomId);
}
if (!mounted) return;
setState(() => _joining.remove(jr.roomId));
if (jr.isVoice) {
// LiveKit only needs the room id + a display name, so connect even if the
// matrix room object hasn't synced locally yet.
final call = ref.read(callStateProvider);
if (call.isActive) await call.hangUp();
ref.read(activeVoiceRoomIdProvider.notifier).state = jr.roomId;
await call.startCall(
roomName: jr.roomId,
roomDisplayName: room?.getLocalizedDisplayname() ?? jr.name,
identity: client.userID ?? '',
audioOnly: true,
voiceChannel: true,
matrixClient: client,
matrixRoomId: jr.roomId,
);
} else if (room != null) {
ref.read(activeRoomIdProvider.notifier).state = room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
} catch (e) {
if (mounted) {
setState(() => _joining.remove(jr.roomId));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content:
Text('Beitritt fehlgeschlagen: ${e.toString().split('\n').first}'),
backgroundColor: widget.pt.danger,
));
}
}
}
Future<void> _applyReorder(
Room space, String categoryKey, List<SpaceChild> newOrder) async {
try {
for (var i = 0; i < newOrder.length; i++) {
final child = newOrder[i];
if (child.roomId == null) continue;
final newOrd = (i + 1).toString().padLeft(4, '0');
if (child.order != newOrd) {
await space.setSpaceChild(child.roomId!,
via: child.via.isEmpty ? null : child.via, order: newOrd);
}
}
} catch (_) {
}
}
@override
Widget build(BuildContext context) {
// Rebuild when rooms list changes (sync)
ref.watch(roomListProvider);
final client = ref.watch(matrixClientProvider).valueOrNull;
final activeRoomId = ref.watch(activeRoomIdProvider);
if (client == null) {
return Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: widget.pt.accent)));
}
final space = client.getRoomById(widget.spaceId);
if (space == null) {
return Center(
child: Text('Space nicht gefunden',
style: TextStyle(color: widget.pt.fgDim, fontSize: 12)));
}
final isAdmin = space.ownPowerLevel >= 50;
final allChildren = space.spaceChildren;
// Separate sub-spaces (categories) from direct rooms
final subSpaceEntries = <SpaceChild>[];
final directEntries = <SpaceChild>[];
for (final c in allChildren) {
if (c.roomId == null) continue;
final room = client.getRoomById(c.roomId!);
if (room == null || room.membership == Membership.leave) continue;
if (room.isSpace) {
subSpaceEntries.add(c);
} else {
directEntries.add(c);
}
}
subSpaceEntries.sort(_compareSpaceChildren);
directEntries.sort(_compareSpaceChildren);
// Collect invited rooms
final invites = allChildren
.where((c) => c.roomId != null)
.map((c) => client.getRoomById(c.roomId!))
.whereType<Room>()
.where((r) => r.membership == Membership.invite)
.toList();
// IDs already rendered in the joined/invited sections — used to avoid showing
// them again under "available channels" from the hierarchy.
final shownIds = <String>{...invites.map((r) => r.id)};
void selectRoom(Room room) {
ref.read(activeRoomIdProvider.notifier).state = room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
void joinVoiceChannel(Room room) async {
final call = ref.read(callStateProvider);
final activeVoice = ref.read(activeVoiceRoomIdProvider);
// Toggle: leave if already in this channel
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;
final userId = client.userID ?? '';
await call.startCall(
roomName: room.id,
roomDisplayName: room.getLocalizedDisplayname(),
identity: userId,
audioOnly: true,
voiceChannel: true,
matrixClient: client,
matrixRoomId: room.id,
);
}
// Build category sections for sub-spaces
List<Widget> categoryWidgets = [];
for (final catEntry in subSpaceEntries) {
final subSpace = client.getRoomById(catEntry.roomId!)!;
final catId = subSpace.id;
final catName = subSpace.getLocalizedDisplayname();
final isCollapsed = widget.collapsed[catId] ?? false;
final catChildren = subSpace.spaceChildren
.where((c) => c.roomId != null)
.map((c) => client.getRoomById(c.roomId!))
.whereType<Room>()
.where((r) =>
r.membership != Membership.leave && !r.isSpace)
.toList();
shownIds.addAll(catChildren.map((r) => r.id));
final filtered = catChildren
.where((r) => widget.filter.isEmpty ||
r.getLocalizedDisplayname()
.toLowerCase()
.contains(widget.filter.toLowerCase()))
.toList();
categoryWidgets.add(_CategoryHeader(
name: catName.toUpperCase(),
isCollapsed: isCollapsed,
pt: widget.pt,
onTap: () => widget.onToggleSection(catId),
));
if (!isCollapsed) {
for (final room in filtered) {
final isVoice = _isVoiceRoom(room);
categoryWidgets.add(_RoomItem(
key: ValueKey('cat_${room.id}'),
room: room,
isActive: isVoice ? false : activeRoomId == room.id,
isInVoice: isVoice && ref.read(activeVoiceRoomIdProvider) == room.id,
pt: widget.pt,
onTap: () => isVoice ? joinVoiceChannel(room) : selectRoom(room),
));
}
}
}
// Build direct rooms section (reorderable for admin)
final directRooms = directEntries
.map((c) => client.getRoomById(c.roomId!))
.whereType<Room>()
.toList();
shownIds.addAll(directRooms.map((r) => r.id));
// Apply local order override if present
final localOrd = _localOrder['__direct__'];
if (localOrd != null) {
directRooms.sort((a, b) {
final ai = localOrd.indexOf(a.id);
final bi = localOrd.indexOf(b.id);
if (ai == -1 && bi == -1) return 0;
if (ai == -1) return 1;
if (bi == -1) return -1;
return ai.compareTo(bi);
});
}
final filteredDirect = directRooms
.where((r) => widget.filter.isEmpty ||
r.getLocalizedDisplayname()
.toLowerCase()
.contains(widget.filter.toLowerCase()))
.toList();
// Joinable channels from the hierarchy that aren't already listed above.
final visibleJoinable = _joinable
.where((jr) =>
!shownIds.contains(jr.roomId) &&
(widget.filter.isEmpty ||
jr.name.toLowerCase().contains(widget.filter.toLowerCase())))
.toList();
// Build the complete list
return ListView(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
children: [
// Invite cards
if (invites.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 2),
child: Text('EINLADUNGEN',
style: TextStyle(
color: widget.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: widget.pt))),
const SizedBox(height: 4),
],
// Sub-space categories
...categoryWidgets,
// Direct rooms
if (filteredDirect.isNotEmpty) ...[
if (subSpaceEntries.isNotEmpty)
_CategoryHeader(
name: 'KANÄLE',
isCollapsed: widget.collapsed['__direct__'] ?? false,
pt: widget.pt,
onTap: () => widget.onToggleSection('__direct__'),
),
if (!(widget.collapsed['__direct__'] ?? false))
if (isAdmin)
// Reorderable list for admins
ReorderableListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
onReorderItem: (oldIdx, newIdx) {
final ids = filteredDirect.map((r) => r.id).toList();
final moved = ids.removeAt(oldIdx);
ids.insert(newIdx, moved);
setState(() => _localOrder['__direct__'] = ids);
// Apply to Matrix
final newOrder = ids
.map((id) =>
directEntries.firstWhere((c) => c.roomId == id,
orElse: () => directEntries.first))
.toList();
_applyReorder(space, '__direct__', newOrder);
},
children: [
for (var i = 0; i < filteredDirect.length; i++)
_DraggableRoomItem(
key: ValueKey(filteredDirect[i].id),
room: filteredDirect[i],
index: i,
isActive: _isVoiceRoom(filteredDirect[i]) ? false : activeRoomId == filteredDirect[i].id,
isInVoice: _isVoiceRoom(filteredDirect[i]) && ref.read(activeVoiceRoomIdProvider) == filteredDirect[i].id,
pt: widget.pt,
onTap: () => _isVoiceRoom(filteredDirect[i]) ? joinVoiceChannel(filteredDirect[i]) : selectRoom(filteredDirect[i]),
),
],
)
else
...filteredDirect.map((r) {
final isVoice = _isVoiceRoom(r);
return _RoomItem(
key: ValueKey(r.id),
room: r,
isActive: isVoice ? false : activeRoomId == r.id,
isInVoice: isVoice && ref.read(activeVoiceRoomIdProvider) == r.id,
pt: widget.pt,
onTap: () => isVoice ? joinVoiceChannel(r) : selectRoom(r),
);
}),
],
// Joinable channels from the space hierarchy (not yet a member)
if (visibleJoinable.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 2),
child: Text('VERFÜGBARE KANÄLE',
style: TextStyle(
color: widget.pt.fgDim,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6)),
),
...visibleJoinable.map((jr) => _JoinableRoomItem(
jr: jr,
joining: _joining.contains(jr.roomId),
pt: widget.pt,
onJoin: () => _joinAndEnter(client, jr),
)),
],
if (filteredDirect.isEmpty &&
categoryWidgets.isEmpty &&
invites.isEmpty &&
visibleJoinable.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Text('Keine Kanäle',
style:
TextStyle(color: widget.pt.fgDim, fontSize: 13))),
),
],
);
}
}
class _JoinableRoom {
final String roomId;
final String name;
final bool isVoice;
final int memberCount;
const _JoinableRoom({
required this.roomId,
required this.name,
required this.isVoice,
required this.memberCount,
});
}
class _JoinableRoomItem extends StatefulWidget {
final _JoinableRoom jr;
final bool joining;
final PyramidTheme pt;
final VoidCallback onJoin;
const _JoinableRoomItem({
required this.jr,
required this.joining,
required this.pt,
required this.onJoin,
});
@override
State<_JoinableRoomItem> createState() => _JoinableRoomItemState();
}
class _JoinableRoomItemState extends State<_JoinableRoomItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final jr = widget.jr;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.joining ? null : widget.onJoin,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(vertical: 1),
padding: EdgeInsets.symmetric(
horizontal: pt.rowPadX, vertical: pt.rowPadY),
decoration: BoxDecoration(
color: _hovered ? pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: Icon(
jr.isVoice ? Icons.mic_rounded : Icons.tag_rounded,
size: 14,
color: pt.fgDim,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
jr.name,
style: TextStyle(
color: pt.fgMuted,
fontSize: 14,
fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
if (widget.joining)
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
else
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _hovered ? pt.accent : pt.bg3,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'Beitreten',
style: TextStyle(
color: _hovered ? pt.accentFg : pt.fgMuted,
fontSize: 11,
fontWeight: FontWeight.w600),
),
),
],
),
),
),
);
}
}
class _CategoryHeader extends StatefulWidget {
final String name;
final bool isCollapsed;
final PyramidTheme pt;
final VoidCallback onTap;
const _CategoryHeader(
{required this.name,
required this.isCollapsed,
required this.pt,
required this.onTap});
@override
State<_CategoryHeader> createState() => _CategoryHeaderState();
}
class _CategoryHeaderState extends State<_CategoryHeader> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 10, 8, 2),
child: Row(children: [
AnimatedRotation(
turns: widget.isCollapsed ? -0.25 : 0,
duration: const Duration(milliseconds: 180),
child: Icon(Icons.keyboard_arrow_down_rounded,
size: 12,
color: _hovered ? pt.fgMuted : pt.fgDim),
),
const SizedBox(width: 4),
Text(
widget.name,
style: TextStyle(
color: _hovered ? pt.fgMuted : pt.fgDim,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
]),
),
),
);
}
}
+132
View File
@@ -0,0 +1,132 @@
part of '../rooms_panel.dart';
class _VoiceParticipantList extends StatelessWidget {
final List<Map<String, dynamic>> participants;
final Client? client;
final PyramidTheme pt;
const _VoiceParticipantList(
{required this.participants, required this.client, required this.pt});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: participants.map((p) {
final avatarUrl = p['avatarUrl'] as String?;
final name = (p['displayName'] as String? ?? '?');
final letter = name.isNotEmpty ? name[0].toUpperCase() : '?';
return Padding(
padding: const EdgeInsets.only(top: 3),
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: client != null && avatarUrl != null
? MxcAvatar(
mxcUri: Uri.tryParse(avatarUrl),
client: client!,
size: 16,
borderRadius: BorderRadius.circular(8),
placeholder: (_) => _Initials(letter: letter, pt: pt, size: 16),
)
: _Initials(letter: letter, pt: pt, size: 16),
),
const SizedBox(width: 6),
Expanded(
child: Text(
name,
style: TextStyle(
color: const Color(0xFF57F287).withAlpha(200),
fontSize: 12,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
Icon(Icons.mic_rounded,
size: 11,
color: const Color(0xFF57F287).withAlpha(140)),
],
),
);
}).toList(),
);
}
}
class _Initials extends StatelessWidget {
final String letter;
final PyramidTheme pt;
final double size;
const _Initials({required this.letter, required this.pt, this.size = 18});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(size / 2),
),
child: Center(
child: Text(letter,
style: TextStyle(
color: pt.fg,
fontSize: size * 0.5,
fontWeight: FontWeight.w700)),
),
);
}
}
class _VoiceBarBtn extends StatefulWidget {
final IconData icon;
final bool active;
final Color activeColor;
final Color inactiveColor;
final PyramidTheme pt;
final VoidCallback onTap;
const _VoiceBarBtn({
required this.icon,
required this.active,
required this.activeColor,
required this.inactiveColor,
required this.pt,
required this.onTap,
});
@override
State<_VoiceBarBtn> createState() => _VoiceBarBtnState();
}
class _VoiceBarBtnState extends State<_VoiceBarBtn> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final color = widget.active ? widget.activeColor : widget.inactiveColor;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: 28,
height: 28,
decoration: BoxDecoration(
color: _hovered ? pt.bg3 : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Center(child: Icon(widget.icon, size: 16, color: color)),
),
),
);
}
}
File diff suppressed because it is too large Load Diff