3c7eb9ddca
- 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)
570 lines
19 KiB
Dart
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,
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|