// Version: 1.0.0 | Created: 2026-04-27 // Public room browser — search Matrix room directory and join rooms. import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix_api_lite.dart' show PublicRoomQueryFilter, PublicRoomsChunk; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/network/matrix_client.dart'; import '../../../shared/widgets/matrix_avatar.dart'; part 'public_rooms_dialog.g.dart'; @riverpod Future> publicRooms( Ref ref, String searchTerm) async { final client = ref.watch(matrixClientProvider); final filter = searchTerm.trim().isEmpty ? null : PublicRoomQueryFilter(genericSearchTerm: searchTerm.trim()); final response = await client.queryPublicRooms( filter: filter, limit: 30, ); return response.chunk; } Future showPublicRoomsDialog(BuildContext context) { return showDialog( context: context, builder: (_) => const _PublicRoomsDialog(), ); } class _PublicRoomsDialog extends ConsumerStatefulWidget { const _PublicRoomsDialog(); @override ConsumerState<_PublicRoomsDialog> createState() => _PublicRoomsDialogState(); } class _PublicRoomsDialogState extends ConsumerState<_PublicRoomsDialog> { final _controller = TextEditingController(); String _searchTerm = ''; Timer? _debounce; String? _joiningRoomId; @override void dispose() { _debounce?.cancel(); _controller.dispose(); super.dispose(); } void _onSearchChanged(String val) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 450), () { if (mounted) setState(() => _searchTerm = val); }); } Future _join(PublicRoomsChunk room) async { if (_joiningRoomId != null) return; setState(() => _joiningRoomId = room.roomId); try { final client = ref.read(matrixClientProvider); await client.joinRoom(room.canonicalAlias ?? room.roomId); if (!mounted) return; Navigator.of(context).pop(); context.push('/rooms/${Uri.encodeComponent(room.roomId)}'); } on Exception catch (e) { if (!mounted) return; setState(() => _joiningRoomId = null); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Could not join room: $e')), ); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final roomsAsync = ref.watch(publicRoomsProvider(_searchTerm)); return Dialog( insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520, maxHeight: 600), child: Column( children: [ // Header Padding( padding: const EdgeInsets.fromLTRB(16, 16, 4, 0), child: Row( children: [ Text( 'Find rooms', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const Spacer(), IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), ), ], ), ), // Search Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: TextField( controller: _controller, autofocus: true, decoration: const InputDecoration( hintText: 'Search public rooms...', prefixIcon: Icon(Icons.search), ), onChanged: _onSearchChanged, ), ), // Results Flexible( child: roomsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (err, _) => Padding( padding: const EdgeInsets.all(16), child: Text('Search failed: $err'), ), data: (rooms) { if (rooms.isEmpty) { return Padding( padding: const EdgeInsets.all(24), child: Text( _searchTerm.isEmpty ? 'No public rooms found.' : 'No rooms match "$_searchTerm".', textAlign: TextAlign.center, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withAlpha(102), ), ), ); } return ListView.separated( shrinkWrap: true, itemCount: rooms.length, separatorBuilder: (_, __) => const Divider(height: 1, indent: 72), itemBuilder: (context, index) { final room = rooms[index]; final name = room.name ?? room.canonicalAlias ?? room.roomId; final isJoining = _joiningRoomId == room.roomId; return ListTile( leading: MatrixAvatar( name: name, avatarUrl: room.avatarUrl?.toString(), radius: 20, ), title: Text( name, overflow: TextOverflow.ellipsis, ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (room.topic != null) Text( room.topic!, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall, ), Text( '${room.numJoinedMembers} member' '${room.numJoinedMembers == 1 ? '' : 's'}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface .withAlpha(120), ), ), ], ), isThreeLine: room.topic != null, trailing: isJoining ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2), ) : OutlinedButton( onPressed: _joiningRoomId != null ? null : () => _join(room), child: const Text('Join'), ), ); }, ); }, ), ), ], ), ), ); } }