- Chats: Direktnachrichten + Gruppen ohne Server zusammen, Filter-Chips (Alle/Ungelesen/Personen/Gruppen), Leerzustaende mit Handlung; virtuelles 'Raeume'-Symbol entfaellt, Server-Kanaele nur noch im Server - Gruppen: runder Gruppen-Avatar, Kopf mit Mitgliederzahl, Anruf-/Video-Knopf - Gruppenanruf: LiveKit + Anwesenheit, Start-Nachricht (Push), Karte 'Gruppenanruf laeuft · Beitreten', Hinweis-Karte im Verlauf - Anrufansicht: aneinandergedrueckte Bubbles (Federphysik, Sprecher waechst), Kacheln umschaltbar, Bildschirm automatisch gross, Gespraechsdauer, Matrix-Namen/-Avatare statt LiveKit-IDs - Tests: test/bubble_physics_test.dart Co-Authored-By: Claude Opus 5.5 <[email protected]>
720 lines
24 KiB
Dart
720 lines
24 KiB
Dart
part of '../rooms_panel.dart';
|
||
|
||
class _DraggableRoomItem extends StatefulWidget {
|
||
final Room room;
|
||
final int index;
|
||
final bool isActive;
|
||
final bool isInVoice;
|
||
final PyramidTheme pt;
|
||
final VoidCallback onTap;
|
||
const _DraggableRoomItem({
|
||
super.key,
|
||
required this.room,
|
||
required this.index,
|
||
required this.isActive,
|
||
this.isInVoice = false,
|
||
required this.pt,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
State<_DraggableRoomItem> createState() => _DraggableRoomItemState();
|
||
}
|
||
|
||
class _DraggableRoomItemState extends State<_DraggableRoomItem> {
|
||
bool _hovered = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return MouseRegion(
|
||
onEnter: (_) => setState(() => _hovered = true),
|
||
onExit: (_) => setState(() => _hovered = false),
|
||
child: Row(children: [
|
||
ReorderableDragStartListener(
|
||
index: widget.index,
|
||
child: MouseRegion(
|
||
cursor: SystemMouseCursors.grab,
|
||
child: AnimatedOpacity(
|
||
opacity: _hovered ? 1.0 : 0.0,
|
||
duration: const Duration(milliseconds: 120),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||
child: Icon(Icons.drag_indicator_rounded,
|
||
size: 12, color: widget.pt.fgDim),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: _RoomItem(
|
||
room: widget.room,
|
||
isActive: widget.isActive,
|
||
isInVoice: widget.isInVoice,
|
||
pt: widget.pt,
|
||
onTap: widget.onTap,
|
||
),
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Filter der gemeinsamen Chatliste (Chips über der Liste).
|
||
enum _ChatFilter { all, unread, people, groups }
|
||
|
||
final _chatFilterProvider =
|
||
StateProvider<_ChatFilter>((ref) => _ChatFilter.all);
|
||
|
||
/// „Chats“: Direktnachrichten und Gruppen zusammen, nach letzter Aktivität
|
||
/// (Bernd 2026-10-09). Kanäle eines Servers stehen nur in ihrem Server.
|
||
class _RoomsList extends ConsumerWidget {
|
||
final List<Room> rooms;
|
||
final String filter;
|
||
final PyramidTheme pt;
|
||
|
||
const _RoomsList({
|
||
required this.rooms,
|
||
required this.filter,
|
||
required this.pt,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final activeRoomId = ref.watch(activeRoomIdProvider);
|
||
final channels = ref.watch(spaceChannelIdsProvider);
|
||
final chatFilter = ref.watch(_chatFilterProvider);
|
||
final query = filter.toLowerCase();
|
||
|
||
final invites =
|
||
rooms.where((r) => r.membership == Membership.invite).toList();
|
||
|
||
bool matches(Room r) {
|
||
switch (chatFilter) {
|
||
case _ChatFilter.all:
|
||
break;
|
||
case _ChatFilter.unread:
|
||
if (!r.isUnread) return false;
|
||
case _ChatFilter.people:
|
||
if (!r.isDirectChat) return false;
|
||
case _ChatFilter.groups:
|
||
if (r.isDirectChat) return false;
|
||
}
|
||
if (query.isEmpty) return true;
|
||
return r.getLocalizedDisplayname().toLowerCase().contains(query);
|
||
}
|
||
|
||
final chats = rooms
|
||
.where((r) =>
|
||
r.membership == Membership.join && isChatListRoom(r, channels))
|
||
.toList();
|
||
final filtered = chats.where(matches).toList();
|
||
final unreadCount = chats.where((r) => r.isUnread).length;
|
||
|
||
return ListView(
|
||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
|
||
children: [
|
||
_ChatFilterChips(
|
||
value: chatFilter,
|
||
unreadCount: unreadCount,
|
||
pt: pt,
|
||
onChanged: (f) => ref.read(_chatFilterProvider.notifier).state = f,
|
||
),
|
||
if (invites.isNotEmpty) ...[
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 2),
|
||
child: Text('EINLADUNGEN',
|
||
style: TextStyle(
|
||
color: pt.accent,
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w700,
|
||
letterSpacing: 0.6)),
|
||
),
|
||
...invites.map((r) => Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||
child: _InviteItem(room: r, pt: pt))),
|
||
const SizedBox(height: 4),
|
||
],
|
||
if (filtered.isEmpty)
|
||
_ChatListEmpty(
|
||
filter: chatFilter,
|
||
searching: query.isNotEmpty,
|
||
hasInvites: invites.isNotEmpty,
|
||
pt: pt,
|
||
)
|
||
else
|
||
...filtered.map((room) {
|
||
final isVoice = _isVoiceRoom(room);
|
||
return _RoomItem(
|
||
key: ValueKey('chat:${room.id}'),
|
||
room: room,
|
||
isActive: isVoice ? false : activeRoomId == room.id,
|
||
isInVoice:
|
||
isVoice && ref.read(activeVoiceRoomIdProvider) == room.id,
|
||
pt: pt,
|
||
// Voice-Räume treten dem Kanal bei (wie in der Space-Liste) –
|
||
// vorher öffnete der Klick hier nur die Chat-Ansicht, ein
|
||
// Standalone-Voice-Channel war per Klick gar nicht beitretbar.
|
||
onTap: () {
|
||
if (isVoice) {
|
||
_toggleVoiceChannel(ref, room);
|
||
} else {
|
||
ref.read(activeRoomIdProvider.notifier).state = room.id;
|
||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||
}
|
||
},
|
||
);
|
||
}),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ChatFilterChips extends StatelessWidget {
|
||
final _ChatFilter value;
|
||
final int unreadCount;
|
||
final PyramidTheme pt;
|
||
final ValueChanged<_ChatFilter> onChanged;
|
||
|
||
const _ChatFilterChips({
|
||
required this.value,
|
||
required this.unreadCount,
|
||
required this.pt,
|
||
required this.onChanged,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
Widget chip(_ChatFilter f, String label) {
|
||
final selected = value == f;
|
||
return Padding(
|
||
padding: const EdgeInsets.only(right: 6),
|
||
child: Semantics(
|
||
button: true,
|
||
selected: selected,
|
||
child: GestureDetector(
|
||
onTap: () => onChanged(selected ? _ChatFilter.all : f),
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 180),
|
||
curve: Curves.easeOutCubic,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: selected ? pt.accentSoft : pt.bg2,
|
||
borderRadius: BorderRadius.circular(999),
|
||
border: Border.all(
|
||
color: selected ? pt.accent.withAlpha(120) : pt.border),
|
||
),
|
||
child: Text(label,
|
||
style: TextStyle(
|
||
color: selected ? pt.accent : pt.fgMuted,
|
||
fontSize: 12.5,
|
||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500)),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return SizedBox(
|
||
height: 40,
|
||
child: ListView(
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
|
||
children: [
|
||
chip(_ChatFilter.all, 'Alle'),
|
||
chip(_ChatFilter.unread,
|
||
unreadCount > 0 ? 'Ungelesen · $unreadCount' : 'Ungelesen'),
|
||
chip(_ChatFilter.people, 'Personen'),
|
||
chip(_ChatFilter.groups, 'Gruppen'),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Leerzustand der Chatliste – mit Handlung statt nur „nichts da“.
|
||
class _ChatListEmpty extends StatelessWidget {
|
||
final _ChatFilter filter;
|
||
final bool searching;
|
||
final bool hasInvites;
|
||
final PyramidTheme pt;
|
||
|
||
const _ChatListEmpty({
|
||
required this.filter,
|
||
required this.searching,
|
||
required this.hasInvites,
|
||
required this.pt,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (hasInvites && filter == _ChatFilter.all && !searching) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
final (IconData icon, String text) = searching
|
||
? (Icons.search_off_rounded, 'Nichts gefunden')
|
||
: switch (filter) {
|
||
_ChatFilter.unread => (Icons.done_all_rounded, 'Alles gelesen'),
|
||
_ChatFilter.people => (
|
||
Icons.person_outline_rounded,
|
||
'Noch keine Unterhaltungen'
|
||
),
|
||
_ChatFilter.groups => (Icons.groups_outlined, 'Noch keine Gruppen'),
|
||
_ChatFilter.all => (
|
||
Icons.chat_bubble_outline_rounded,
|
||
'Noch keine Chats'
|
||
),
|
||
};
|
||
final showAction = !searching && filter != _ChatFilter.unread;
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 40, 16, 16),
|
||
child: Column(
|
||
children: [
|
||
Icon(icon, size: 34, color: pt.fgDim),
|
||
const SizedBox(height: 10),
|
||
Text(text, style: TextStyle(color: pt.fgMuted, fontSize: 14)),
|
||
if (showAction) ...[
|
||
const SizedBox(height: 14),
|
||
FilledButton.tonalIcon(
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: pt.accentSoft,
|
||
foregroundColor: pt.accent,
|
||
),
|
||
icon: Icon(filter == _ChatFilter.groups
|
||
? Icons.group_add_rounded
|
||
: Icons.edit_rounded, size: 18),
|
||
label: Text(filter == _ChatFilter.groups
|
||
? 'Gruppe erstellen'
|
||
: 'Neue Unterhaltung'),
|
||
onPressed: () => showDialog(
|
||
context: context,
|
||
builder: (_) => filter == _ChatFilter.groups
|
||
? const CreateJoinDialog()
|
||
: const NewDmDialog(),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Beitreten/Verlassen eines Voice-Channels aus der Raumliste – exakt das
|
||
/// Verhalten von `joinVoiceChannel` in rooms_space_list.dart (Toggle beim
|
||
/// aktiven Kanal, Wechsel legt vorher auf).
|
||
Future<void> _toggleVoiceChannel(WidgetRef ref, Room room) async {
|
||
final call = ref.read(voiceChannelProvider);
|
||
final activeVoice = ref.read(activeVoiceRoomIdProvider);
|
||
if (call.isVoiceChannel && activeVoice == room.id) {
|
||
ref.read(activeVoiceRoomIdProvider.notifier).state = null;
|
||
await call.hangUp();
|
||
return;
|
||
}
|
||
if (call.isActive) await call.hangUp();
|
||
ref.read(activeVoiceRoomIdProvider.notifier).state = room.id;
|
||
await call.startCall(
|
||
roomName: room.id,
|
||
roomDisplayName: room.getLocalizedDisplayname(),
|
||
identity: room.client.userID ?? '',
|
||
audioOnly: true,
|
||
voiceChannel: true,
|
||
matrixClient: room.client,
|
||
matrixRoomId: room.id,
|
||
);
|
||
}
|
||
|
||
class _RoomItem extends ConsumerStatefulWidget {
|
||
final Room room;
|
||
final bool isActive;
|
||
final bool isInVoice;
|
||
final PyramidTheme pt;
|
||
final VoidCallback onTap;
|
||
|
||
const _RoomItem({
|
||
super.key,
|
||
required this.room,
|
||
required this.isActive,
|
||
this.isInVoice = false,
|
||
required this.pt,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
ConsumerState<_RoomItem> createState() => _RoomItemState();
|
||
}
|
||
|
||
class _RoomItemState extends ConsumerState<_RoomItem> {
|
||
bool _hovered = false;
|
||
|
||
Future<void> _leaveRoom() async {
|
||
final pt = widget.pt;
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
backgroundColor: pt.bg2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(pt.rXl),
|
||
side: BorderSide(color: pt.border)),
|
||
title: Text('Raum verlassen?',
|
||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||
content: Text(
|
||
'"${widget.room.getLocalizedDisplayname()}" verlassen?',
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
|
||
ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: pt.danger,
|
||
foregroundColor: Colors.white,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(8))),
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: const Text('Verlassen'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed != true || !mounted) return;
|
||
try {
|
||
final id = widget.room.id;
|
||
await widget.room.leave();
|
||
if (mounted && ref.read(activeRoomIdProvider) == id) {
|
||
ref.read(activeRoomIdProvider.notifier).state = null;
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
Future<void> _leaveDm() async {
|
||
final pt = widget.pt;
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
backgroundColor: pt.bg2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(pt.rXl),
|
||
side: BorderSide(color: pt.border)),
|
||
title: Text('Chat verlassen?',
|
||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||
content: Text(
|
||
'Unterhaltung mit "${widget.room.getLocalizedDisplayname()}" schließen?',
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
|
||
ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: pt.danger,
|
||
foregroundColor: Colors.white,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(8))),
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: const Text('Verlassen'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed != true || !mounted) return;
|
||
try {
|
||
final id = widget.room.id;
|
||
await widget.room.leave();
|
||
if (mounted) {
|
||
if (ref.read(activeRoomIdProvider) == id) {
|
||
ref.read(activeRoomIdProvider.notifier).state = null;
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
Future<void> _showRoomSettings() async {
|
||
await showDialog(
|
||
context: context,
|
||
builder: (_) => RoomSettingsDialog(room: widget.room),
|
||
);
|
||
}
|
||
|
||
Future<void> _inviteUser() async {
|
||
final pt = widget.pt;
|
||
final ctrl = TextEditingController();
|
||
final userId = await showDialog<String>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
backgroundColor: pt.bg2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(pt.rXl),
|
||
side: BorderSide(color: pt.border)),
|
||
title: Text('Nutzer einladen',
|
||
style: TextStyle(
|
||
color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||
content: TextField(
|
||
controller: ctrl,
|
||
autofocus: true,
|
||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||
decoration: InputDecoration(
|
||
hintText: '@nutzer:server',
|
||
hintStyle: TextStyle(color: pt.fgDim, fontSize: 12),
|
||
filled: true,
|
||
fillColor: pt.bg3,
|
||
isDense: true,
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: BorderSide(color: pt.border)),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: BorderSide(color: pt.border)),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: BorderSide(color: pt.accent)),
|
||
),
|
||
cursorColor: pt.accent,
|
||
onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx),
|
||
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, ctrl.text.trim()),
|
||
child: Text('Einladen', style: TextStyle(color: pt.accent))),
|
||
],
|
||
),
|
||
);
|
||
ctrl.dispose();
|
||
if (userId == null || userId.isEmpty || !mounted) return;
|
||
try {
|
||
await widget.room.invite(userId);
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||
content: Text('Fehler: ${e.toString().split('\n').first}'),
|
||
backgroundColor: widget.pt.danger,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Touch-friendly action menu — opened via long-press (the hover menu is not
|
||
/// reachable on touch devices).
|
||
void _showContextMenu() {
|
||
final pt = widget.pt;
|
||
final room = widget.room;
|
||
final isDm = room.isDirectChat;
|
||
|
||
Widget action(IconData icon, String label, VoidCallback onTap,
|
||
{bool danger = false}) {
|
||
return ListTile(
|
||
leading: Icon(icon, size: 20, color: danger ? pt.danger : pt.fgMuted),
|
||
title: Text(label,
|
||
style: TextStyle(
|
||
color: danger ? pt.danger : pt.fg, fontSize: 14)),
|
||
onTap: () {
|
||
Navigator.pop(context);
|
||
onTap();
|
||
},
|
||
);
|
||
}
|
||
|
||
showModalBottomSheet(
|
||
context: context,
|
||
backgroundColor: pt.bg2,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||
builder: (ctx) => SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||
child: Row(children: [
|
||
Expanded(
|
||
child: Text(room.getLocalizedDisplayname(),
|
||
style: TextStyle(
|
||
color: pt.fg,
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w700),
|
||
overflow: TextOverflow.ellipsis),
|
||
),
|
||
]),
|
||
),
|
||
Divider(height: 1, color: pt.border),
|
||
if (!isDm) ...[
|
||
action(Icons.person_add_outlined, 'Nutzer einladen', _inviteUser),
|
||
action(Icons.settings_rounded, 'Einstellungen', _showRoomSettings),
|
||
],
|
||
action(
|
||
Icons.exit_to_app_rounded,
|
||
isDm ? 'Chat verlassen' : 'Raum verlassen',
|
||
isDm ? _leaveDm : _leaveRoom,
|
||
danger: true,
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pt = widget.pt;
|
||
final room = widget.room;
|
||
final active = widget.isActive;
|
||
final inVoice = widget.isInVoice;
|
||
final name = room.getLocalizedDisplayname();
|
||
final unread = room.notificationCount;
|
||
final isDm = room.isDirectChat;
|
||
final isVoice = _isVoiceRoom(room);
|
||
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
|
||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||
|
||
// Handy: Messenger-Kachel mit Vorschau (rooms_mobile_tile.dart).
|
||
if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
|
||
return _MobileRoomTile(
|
||
room: room,
|
||
isActive: active,
|
||
pt: pt,
|
||
myUserId: client?.userID,
|
||
onTap: widget.onTap,
|
||
onLongPress: _showContextMenu,
|
||
);
|
||
}
|
||
|
||
Color textColor = (active || inVoice || unread > 0 || _hovered) ? pt.fg : pt.fgMuted;
|
||
|
||
return MouseRegion(
|
||
cursor: SystemMouseCursors.click,
|
||
onEnter: (_) => setState(() => _hovered = true),
|
||
onExit: (_) => setState(() => _hovered = false),
|
||
child: GestureDetector(
|
||
onTap: widget.onTap,
|
||
onLongPress: _showContextMenu,
|
||
child: Stack(
|
||
children: [
|
||
if (active)
|
||
Positioned(
|
||
left: 0,
|
||
top: 6,
|
||
bottom: 6,
|
||
child: Container(
|
||
width: 3,
|
||
decoration: BoxDecoration(
|
||
color: pt.accent,
|
||
borderRadius: const BorderRadius.only(
|
||
topRight: Radius.circular(2),
|
||
bottomRight: Radius.circular(2),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
AnimatedContainer(
|
||
duration: const Duration(milliseconds: 150),
|
||
curve: Curves.easeOutCubic,
|
||
margin: const EdgeInsets.symmetric(vertical: 1),
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: pt.rowPadX, vertical: pt.rowPadY),
|
||
decoration: BoxDecoration(
|
||
color: active
|
||
? pt.accentSoft
|
||
: inVoice
|
||
? const Color(0xFF57F287).withAlpha(25)
|
||
: _hovered ? pt.bgHover : Colors.transparent,
|
||
borderRadius: BorderRadius.circular(pt.rSm),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
if (isDm) ...[
|
||
_DmAvatar(room: room, pt: pt),
|
||
] else if (!isVoice &&
|
||
!ref
|
||
.watch(spaceChannelIdsProvider)
|
||
.contains(room.id)) ...[
|
||
_RoomTileAvatar(room: room, pt: pt, size: 22),
|
||
] else ...[
|
||
SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: Icon(
|
||
isVoice ? Icons.mic_rounded : _roomIcon(room),
|
||
size: 14,
|
||
color: isVoice
|
||
? (inVoice ? const Color(0xFF57F287) : _hovered ? const Color(0xFF57F287) : const Color(0xFF57F287).withAlpha(180))
|
||
: (active ? pt.accent : _hovered ? pt.fg : pt.fgDim),
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
name,
|
||
style: TextStyle(
|
||
color: textColor,
|
||
fontSize: 14,
|
||
fontWeight: unread > 0 || active || inVoice
|
||
? FontWeight.w600
|
||
: FontWeight.w500,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
if (isVoice && voiceParticipants.isNotEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: 4),
|
||
child: Text(
|
||
'${voiceParticipants.length}',
|
||
style: TextStyle(
|
||
color: const Color(0xFF57F287).withAlpha(200),
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w600),
|
||
),
|
||
),
|
||
if (!isVoice && _hovered) ...[
|
||
if (isDm)
|
||
_HoverAction(icon: Icons.close, pt: pt, onTap: _leaveDm)
|
||
else
|
||
_RoomMenuBtn(
|
||
pt: pt,
|
||
onInvite: _inviteUser,
|
||
onSettings: _showRoomSettings,
|
||
onLeave: _leaveRoom,
|
||
),
|
||
] else if (!isVoice && unread > 0) ...[
|
||
_UnreadBadge(
|
||
count: unread,
|
||
isMention: room.highlightCount > 0,
|
||
pt: pt),
|
||
],
|
||
],
|
||
),
|
||
// Voice participants (Discord-style named rows)
|
||
if (isVoice && voiceParticipants.isNotEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 2, left: 24),
|
||
child: _VoiceParticipantList(
|
||
participants: voiceParticipants,
|
||
client: client,
|
||
pt: pt,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
IconData _roomIcon(Room room) {
|
||
if (room.encrypted) return Icons.lock_outline_rounded;
|
||
return Icons.tag_rounded;
|
||
}
|
||
}
|