Files
pyramid/lib/features/rooms/panel/rooms_header.dart
T
Bernd Steckmeister a863fa3b9a 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>
2026-07-03 11:00:22 +02:00

502 lines
18 KiB
Dart

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,
),
],
),
);
}
}