401bd6a7f8
- document_viewer: Matrix4.translate/scale durch translateByDouble/ scaleByDouble ersetzt (PDF-Zoom-Transformation, gleiches Verhalten) - rooms_panel: onReorder durch onReorderItem ersetzt (Index-Korrektur jetzt intern erledigt), implementation_imports-Hinweis kommentiert (SpaceChild wird vom matrix-Paket nicht öffentlich exportiert) - voip_manager: getSources()-Override kommentiert (erzwungen durch abstrakte MediaDevices-Klasse, die die Methode selbst deprecated hat) - settings_modal: BuildContext-Nutzung nach await mit context.mounted abgesichert (Geräte umbenennen, Passwort-Abfrage bei Massenlogout) Verbleibend: chat_provider.dart onUpdate (Timeline-Redecrypt-Pfad, bewusst nicht angefasst ohne dedizierten E2EE-Test, siehe PROGRESS.md).
2556 lines
88 KiB
Dart
2556 lines
88 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart' hide Visibility;
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
// SpaceChild wird vom matrix-Paket nicht öffentlich exportiert.
|
|
// ignore: implementation_imports
|
|
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';
|
|
|
|
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()],
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Space invite view ─────────────────────────────────────────────────────────
|
|
|
|
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'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Header ───────────────────────────────────────────────────────────────────
|
|
|
|
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)),
|
|
]);
|
|
}
|
|
|
|
// ─── Notifications button ──────────────────────────────────────────────────────
|
|
|
|
/// Glocke im Header: Popup mit allen Räumen, die ungelesene Nachrichten oder
|
|
/// Mentions haben. Tippen öffnet den jeweiligen Raum.
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── 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 = {};
|
|
|
|
// 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))),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Joinable room (from space hierarchy) ─────────────────────────────────────
|
|
|
|
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── 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,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
}
|
|
|
|
// ─── 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;
|
|
// 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))),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── 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))),
|
|
);
|
|
}
|
|
}
|