refactor: Gott-Datei rooms_panel.dart in 8 Dateien aufgeteilt
Gleiche Technik wie bei settings_modal.dart/message_group.dart: reine Verschiebung per Dart part/part-of, keine Logikänderung. rooms_panel.dart schrumpft von 2555 auf 31 Zeilen (nur noch Bibliothekskopf). Verifiziert per automatisiertem Blockvergleich gegen das Original und flutter analyze. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,569 @@
|
||||
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(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))),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user