Files
pyramid/lib/features/rooms/panel/rooms_space_list.dart
T
Bernd Steckmeister 3c7eb9ddca refactor: M2-Call-Pilot – Module call_signaling + voice_channel mit Fassaden (Entscheidung 2)
- CallSignalingService/callSignalingProvider und VoiceChannelService/
  voiceChannelProvider als einzige oeffentliche Schnittstellen
- voip_manager.dart / livekit_call_manager.dart in ihre Module verschoben,
  Logik unangetastet (nur Klassen-Koepfe, @override, Imports, Provider-Namen)
- Alle Konsumenten entkoppelt; Kompositions-Punkt matrix_client.dart als
  dokumentierte Ausnahme. analyze sauber, 29 Tests gruen, Windows-Start ok.
  Call-Klick-/Geraetetest steht aus (PROGRESS.md)
2026-07-06 12:42:59 +02:00

570 lines
19 KiB
Dart

part of '../rooms_panel.dart';
class _SpaceRoomsList extends ConsumerStatefulWidget {
final String spaceId;
final String filter;
final Map<String, bool> collapsed;
final PyramidTheme pt;
final ValueChanged<String> onToggleSection;
const _SpaceRoomsList({
required this.spaceId,
required this.filter,
required this.collapsed,
required this.pt,
required this.onToggleSection,
});
@override
ConsumerState<_SpaceRoomsList> createState() => _SpaceRoomsListState();
}
class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
// Local order override after drag-and-drop (room IDs in order per category key)
final Map<String, List<String>> _localOrder = {};
// Joinable channels discovered via the space hierarchy (not yet a member).
List<_JoinableRoom> _joinable = [];
final Set<String> _joining = {};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final client = ref.read(matrixClientProvider).valueOrNull;
if (client != null) _loadHierarchy(client);
});
}
@override
void didUpdateWidget(covariant _SpaceRoomsList old) {
super.didUpdateWidget(old);
if (old.spaceId != widget.spaceId) {
setState(() => _joinable = []);
final client = ref.read(matrixClientProvider).valueOrNull;
if (client != null) _loadHierarchy(client);
}
}
/// Queries the server-side space hierarchy and keeps only rooms the user is
/// not yet a member of — these are the channels they can still join.
Future<void> _loadHierarchy(Client client) async {
try {
final resp = await client.getSpaceHierarchy(
widget.spaceId,
maxDepth: 3,
suggestedOnly: false,
);
final joinable = <_JoinableRoom>[];
for (final chunk in resp.rooms) {
if (chunk.roomId == widget.spaceId) continue;
if (chunk.roomType == 'm.space') continue; // skip sub-spaces
// Keep every channel the hierarchy reports. The build method filters out
// the ones already rendered in the joined sections — relying on local
// membership here is unreliable because Continuwuity's sync lags behind.
joinable.add(_JoinableRoom(
roomId: chunk.roomId,
name: chunk.name ?? chunk.canonicalAlias ?? chunk.roomId,
isVoice: chunk.roomType == _kVoiceRoomType,
memberCount: chunk.numJoinedMembers,
));
}
if (mounted) setState(() => _joinable = joinable);
} catch (_) {
// Hierarchy endpoint unavailable / no permission — silently ignore.
}
}
Future<void> _joinAndEnter(Client client, _JoinableRoom jr) async {
setState(() => _joining.add(jr.roomId));
try {
await client.joinRoomById(jr.roomId).timeout(const Duration(seconds: 20));
// Give the sync a brief chance to deliver the room object, but don't block
// on it — the local membership may lag behind the server.
Room? room = client.getRoomById(jr.roomId);
if (room == null) {
try {
await client.onSync.stream
.firstWhere((_) => client.getRoomById(jr.roomId) != null)
.timeout(const Duration(seconds: 4));
} catch (_) {}
room = client.getRoomById(jr.roomId);
}
if (!mounted) return;
setState(() => _joining.remove(jr.roomId));
if (jr.isVoice) {
// LiveKit only needs the room id + a display name, so connect even if the
// matrix room object hasn't synced locally yet.
final call = ref.read(voiceChannelProvider);
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(voiceChannelProvider);
final activeVoice = ref.read(activeVoiceRoomIdProvider);
// Toggle: leave if already in this channel
if (call.isVoiceChannel && activeVoice == room.id) {
ref.read(activeVoiceRoomIdProvider.notifier).state = null;
await call.hangUp();
return;
}
if (call.isActive) await call.hangUp();
ref.read(activeVoiceRoomIdProvider.notifier).state = room.id;
final userId = client.userID ?? '';
await call.startCall(
roomName: room.id,
roomDisplayName: room.getLocalizedDisplayname(),
identity: userId,
audioOnly: true,
voiceChannel: true,
matrixClient: client,
matrixRoomId: room.id,
);
}
// Build category sections for sub-spaces
List<Widget> categoryWidgets = [];
for (final catEntry in subSpaceEntries) {
final subSpace = client.getRoomById(catEntry.roomId!)!;
final catId = subSpace.id;
final catName = subSpace.getLocalizedDisplayname();
final isCollapsed = widget.collapsed[catId] ?? false;
final catChildren = subSpace.spaceChildren
.where((c) => c.roomId != null)
.map((c) => client.getRoomById(c.roomId!))
.whereType<Room>()
.where((r) =>
r.membership != Membership.leave && !r.isSpace)
.toList();
shownIds.addAll(catChildren.map((r) => r.id));
final filtered = catChildren
.where((r) => widget.filter.isEmpty ||
r.getLocalizedDisplayname()
.toLowerCase()
.contains(widget.filter.toLowerCase()))
.toList();
categoryWidgets.add(_CategoryHeader(
name: catName.toUpperCase(),
isCollapsed: isCollapsed,
pt: widget.pt,
onTap: () => widget.onToggleSection(catId),
));
if (!isCollapsed) {
for (final room in filtered) {
final isVoice = _isVoiceRoom(room);
categoryWidgets.add(_RoomItem(
key: ValueKey('cat_${room.id}'),
room: room,
isActive: isVoice ? false : activeRoomId == room.id,
isInVoice: isVoice && ref.read(activeVoiceRoomIdProvider) == room.id,
pt: widget.pt,
onTap: () => isVoice ? joinVoiceChannel(room) : selectRoom(room),
));
}
}
}
// Build direct rooms section (reorderable for admin)
final directRooms = directEntries
.map((c) => client.getRoomById(c.roomId!))
.whereType<Room>()
.toList();
shownIds.addAll(directRooms.map((r) => r.id));
// Apply local order override if present
final localOrd = _localOrder['__direct__'];
if (localOrd != null) {
directRooms.sort((a, b) {
final ai = localOrd.indexOf(a.id);
final bi = localOrd.indexOf(b.id);
if (ai == -1 && bi == -1) return 0;
if (ai == -1) return 1;
if (bi == -1) return -1;
return ai.compareTo(bi);
});
}
final filteredDirect = directRooms
.where((r) => widget.filter.isEmpty ||
r.getLocalizedDisplayname()
.toLowerCase()
.contains(widget.filter.toLowerCase()))
.toList();
// Joinable channels from the hierarchy that aren't already listed above.
final visibleJoinable = _joinable
.where((jr) =>
!shownIds.contains(jr.roomId) &&
(widget.filter.isEmpty ||
jr.name.toLowerCase().contains(widget.filter.toLowerCase())))
.toList();
// Build the complete list
return ListView(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
children: [
// Invite cards
if (invites.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 2),
child: Text('EINLADUNGEN',
style: TextStyle(
color: widget.pt.accent,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6)),
),
...invites.map((r) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: _InviteItem(room: r, pt: widget.pt))),
const SizedBox(height: 4),
],
// Sub-space categories
...categoryWidgets,
// Direct rooms
if (filteredDirect.isNotEmpty) ...[
if (subSpaceEntries.isNotEmpty)
_CategoryHeader(
name: 'KANÄLE',
isCollapsed: widget.collapsed['__direct__'] ?? false,
pt: widget.pt,
onTap: () => widget.onToggleSection('__direct__'),
),
if (!(widget.collapsed['__direct__'] ?? false))
if (isAdmin)
// Reorderable list for admins
ReorderableListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
onReorderItem: (oldIdx, newIdx) {
final ids = filteredDirect.map((r) => r.id).toList();
final moved = ids.removeAt(oldIdx);
ids.insert(newIdx, moved);
setState(() => _localOrder['__direct__'] = ids);
// Apply to Matrix
final newOrder = ids
.map((id) =>
directEntries.firstWhere((c) => c.roomId == id,
orElse: () => directEntries.first))
.toList();
_applyReorder(space, '__direct__', newOrder);
},
children: [
for (var i = 0; i < filteredDirect.length; i++)
_DraggableRoomItem(
key: ValueKey(filteredDirect[i].id),
room: filteredDirect[i],
index: i,
isActive: _isVoiceRoom(filteredDirect[i]) ? false : activeRoomId == filteredDirect[i].id,
isInVoice: _isVoiceRoom(filteredDirect[i]) && ref.read(activeVoiceRoomIdProvider) == filteredDirect[i].id,
pt: widget.pt,
onTap: () => _isVoiceRoom(filteredDirect[i]) ? joinVoiceChannel(filteredDirect[i]) : selectRoom(filteredDirect[i]),
),
],
)
else
...filteredDirect.map((r) {
final isVoice = _isVoiceRoom(r);
return _RoomItem(
key: ValueKey(r.id),
room: r,
isActive: isVoice ? false : activeRoomId == r.id,
isInVoice: isVoice && ref.read(activeVoiceRoomIdProvider) == r.id,
pt: widget.pt,
onTap: () => isVoice ? joinVoiceChannel(r) : selectRoom(r),
);
}),
],
// Joinable channels from the space hierarchy (not yet a member)
if (visibleJoinable.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 2),
child: Text('VERFÜGBARE KANÄLE',
style: TextStyle(
color: widget.pt.fgDim,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6)),
),
...visibleJoinable.map((jr) => _JoinableRoomItem(
jr: jr,
joining: _joining.contains(jr.roomId),
pt: widget.pt,
onJoin: () => _joinAndEnter(client, jr),
)),
],
if (filteredDirect.isEmpty &&
categoryWidgets.isEmpty &&
invites.isEmpty &&
visibleJoinable.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Text('Keine Kanäle',
style:
TextStyle(color: widget.pt.fgDim, fontSize: 13))),
),
],
);
}
}
class _JoinableRoom {
final String roomId;
final String name;
final bool isVoice;
final int memberCount;
const _JoinableRoom({
required this.roomId,
required this.name,
required this.isVoice,
required this.memberCount,
});
}
class _JoinableRoomItem extends StatefulWidget {
final _JoinableRoom jr;
final bool joining;
final PyramidTheme pt;
final VoidCallback onJoin;
const _JoinableRoomItem({
required this.jr,
required this.joining,
required this.pt,
required this.onJoin,
});
@override
State<_JoinableRoomItem> createState() => _JoinableRoomItemState();
}
class _JoinableRoomItemState extends State<_JoinableRoomItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final jr = widget.jr;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.joining ? null : widget.onJoin,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(vertical: 1),
padding: EdgeInsets.symmetric(
horizontal: pt.rowPadX, vertical: pt.rowPadY),
decoration: BoxDecoration(
color: _hovered ? pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: Icon(
jr.isVoice ? Icons.mic_rounded : Icons.tag_rounded,
size: 14,
color: pt.fgDim,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
jr.name,
style: TextStyle(
color: pt.fgMuted,
fontSize: 14,
fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
if (widget.joining)
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
else
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _hovered ? pt.accent : pt.bg3,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'Beitreten',
style: TextStyle(
color: _hovered ? pt.accentFg : pt.fgMuted,
fontSize: 11,
fontWeight: FontWeight.w600),
),
),
],
),
),
),
);
}
}
class _CategoryHeader extends StatefulWidget {
final String name;
final bool isCollapsed;
final PyramidTheme pt;
final VoidCallback onTap;
const _CategoryHeader(
{required this.name,
required this.isCollapsed,
required this.pt,
required this.onTap});
@override
State<_CategoryHeader> createState() => _CategoryHeaderState();
}
class _CategoryHeaderState extends State<_CategoryHeader> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 10, 8, 2),
child: Row(children: [
AnimatedRotation(
turns: widget.isCollapsed ? -0.25 : 0,
duration: const Duration(milliseconds: 180),
child: Icon(Icons.keyboard_arrow_down_rounded,
size: 12,
color: _hovered ? pt.fgMuted : pt.fgDim),
),
const SizedBox(width: 4),
Text(
widget.name,
style: TextStyle(
color: _hovered ? pt.fgMuted : pt.fgDim,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
]),
),
),
);
}
}