cdf48c4d45
- update_checker: Gitea at git.steggi-matrix.work, Windows + Android support, asset URL detection - update_download_dialog: animated Pyramid logo, progress bar, platform install (exe/apk) - update_banner: Herunterladen button triggers dialog, dismiss option - Android: REQUEST_INSTALL_PACKAGES permission + FileProvider for APK install - post-commit hook: auto-push to Gitea on every commit - chat_input: larger send button (60px desktop), more padding, bigger icon - rooms_panel: space menu visible on mobile (leave space fix) - docs: feature-checklist.md added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1925 lines
65 KiB
Dart
1925 lines
65 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart' hide Visibility;
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
import 'package:matrix/src/utils/space_child.dart';
|
|
import 'package:pyramid/core/app_state.dart';
|
|
import 'package:pyramid/core/matrix_client.dart';
|
|
import 'package:pyramid/core/theme.dart';
|
|
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
|
import 'package:pyramid/features/call/mini_call_widget.dart';
|
|
import 'package:pyramid/features/rooms/user_panel.dart';
|
|
import 'package:pyramid/features/spaces/space_admin_dialog.dart';
|
|
import 'package:pyramid/widgets/create_join_dialog.dart';
|
|
import 'package:pyramid/widgets/hover_region.dart';
|
|
import 'package:pyramid/widgets/mxc_image.dart';
|
|
import 'package:pyramid/widgets/new_dm_dialog.dart';
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
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);
|
|
}
|
|
|
|
// ─── Panel shell ──────────────────────────────────────────────────────────────
|
|
|
|
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';
|
|
|
|
return Container(
|
|
color: pt.bg1,
|
|
child: Column(
|
|
children: [
|
|
_Header(pt: pt, spaceId: activeSpaceId),
|
|
_FilterInput(
|
|
controller: _filterCtrl,
|
|
pt: pt,
|
|
onChanged: (v) => setState(() => _filter = v),
|
|
),
|
|
Expanded(
|
|
child: 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);
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
if (call.isActive && !call.isVoiceChannel) MiniCallWidget(call: call),
|
|
if (voip.currentCall != null &&
|
|
voip.currentCall!.state != CallState.kEnded)
|
|
MiniCallWidget(call: voip),
|
|
const UserPanel(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Header ───────────────────────────────────────────────────────────────────
|
|
|
|
enum _SpaceMenuItem {
|
|
newDm, 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.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)),
|
|
];
|
|
} 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 space banner — prefer local override (set immediately on save)
|
|
// over the state event (only available after next Matrix sync)
|
|
final bannerOverrides = ref.watch(spaceBannerOverrideProvider);
|
|
final bannerUrl = isRealSpace && activeSpace != null
|
|
? (bannerOverrides.containsKey(activeSpace.id)
|
|
? bannerOverrides[activeSpace.id]
|
|
: activeSpace.getState(_kSpaceBannerType)?.content['url'] as String?)
|
|
: null;
|
|
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)
|
|
PyrIconBtn(
|
|
icon: const Icon(Icons.notifications_none_rounded),
|
|
tooltip: 'Notifications',
|
|
onPressed: () {},
|
|
),
|
|
PopupMenuButton<_SpaceMenuItem>(
|
|
tooltip: 'Optionen',
|
|
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.keyboard_arrow_down_rounded,
|
|
color: hasBanner ? Colors.white70 : pt.fgDim, size: 16),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
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)),
|
|
]);
|
|
}
|
|
|
|
// ─── Filter input ──────────────────────────────────────────────────────────────
|
|
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Space rooms list ──────────────────────────────────────────────────────────
|
|
|
|
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 = {};
|
|
|
|
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();
|
|
|
|
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();
|
|
|
|
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();
|
|
|
|
// 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();
|
|
|
|
// 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,
|
|
onReorder: (oldIdx, newIdx) {
|
|
if (newIdx > 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),
|
|
);
|
|
}),
|
|
],
|
|
if (filteredDirect.isEmpty &&
|
|
categoryWidgets.isEmpty &&
|
|
invites.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Center(
|
|
child: Text('Keine Kanäle',
|
|
style:
|
|
TextStyle(color: widget.pt.fgDim, fontSize: 13))),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Category header ───────────────────────────────────────────────────────────
|
|
|
|
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,
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Draggable room item (admin only) ─────────────────────────────────────────
|
|
|
|
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,
|
|
),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Regular rooms list (DMs + all-rooms) ─────────────────────────────────────
|
|
|
|
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;
|
|
},
|
|
)),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Section header ────────────────────────────────────────────────────────────
|
|
|
|
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),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Room item ─────────────────────────────────────────────────────────────────
|
|
|
|
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,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
@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,
|
|
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;
|
|
}
|
|
}
|
|
|
|
// ─── Voice participant list (Discord-style named rows) ────────────────────────
|
|
|
|
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)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Invite item ───────────────────────────────────────────────────────────────
|
|
|
|
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)),
|
|
),
|
|
),
|
|
]),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── DM avatar ─────────────────────────────────────────────────────────────────
|
|
|
|
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;
|
|
if (p.currentlyActive == true) 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))),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Hover action ──────────────────────────────────────────────────────────────
|
|
|
|
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)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Unread badge ──────────────────────────────────────────────────────────────
|
|
|
|
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))),
|
|
);
|
|
}
|
|
}
|