// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // User search dialog — search Matrix user directory and start a DM. // Triggered from the rooms screen "New message" button. 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 Profile; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/network/matrix_client.dart'; import '../../../shared/utils/mxc_url.dart'; import '../../../shared/widgets/matrix_avatar.dart'; part 'user_search_dialog.g.dart'; // --------------------------------------------------------------------------- // Provider // --------------------------------------------------------------------------- @riverpod Future> searchUsers(Ref ref, String term) async { if (term.trim().length < 2) return []; final client = ref.watch(matrixClientProvider); final result = await client.searchUserDirectory(term.trim(), limit: 20); return result.results; } // --------------------------------------------------------------------------- // Dialog // --------------------------------------------------------------------------- /// Show user search as a modal dialog. On user selected, navigates to DM room. Future showUserSearchDialog(BuildContext context) async { return showDialog( context: context, builder: (_) => const _UserSearchDialog(), ); } class _UserSearchDialog extends ConsumerStatefulWidget { const _UserSearchDialog(); @override ConsumerState<_UserSearchDialog> createState() => _UserSearchDialogState(); } class _UserSearchDialogState extends ConsumerState<_UserSearchDialog> { final _controller = TextEditingController(); String _searchTerm = ''; bool _isStartingDm = false; Timer? _debounce; @override void dispose() { _debounce?.cancel(); _controller.dispose(); super.dispose(); } void _onSearchChanged(String val) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 400), () { if (mounted) setState(() => _searchTerm = val); }); } Future _startDm(String userId) async { if (_isStartingDm) return; setState(() => _isStartingDm = true); try { final client = ref.read(matrixClientProvider); final roomId = await client.startDirectChat(userId); if (!mounted) return; Navigator.of(context).pop(); // close the dialog context.push('/rooms/${Uri.encodeComponent(roomId)}'); } on Exception catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Could not start conversation: $e')), ); } finally { if (mounted) setState(() => _isStartingDm = false); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Dialog( insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480, maxHeight: 520), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Header Padding( padding: const EdgeInsets.fromLTRB(16, 16, 4, 0), child: Row( children: [ Text( 'New message', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const Spacer(), IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), ), ], ), ), // Search field Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: TextField( controller: _controller, autofocus: true, decoration: const InputDecoration( hintText: 'Search by name or user ID...', prefixIcon: Icon(Icons.search), ), onChanged: _onSearchChanged, ), ), // Results Flexible( child: _searchTerm.trim().length < 2 ? Padding( padding: const EdgeInsets.all(24), child: Text( 'Type a name or @user:server to search.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withAlpha(102), ), textAlign: TextAlign.center, ), ) : _SearchResults( term: _searchTerm, isLoading: _isStartingDm, onSelect: _startDm, ), ), ], ), ), ); } } class _SearchResults extends ConsumerWidget { const _SearchResults({ required this.term, required this.isLoading, required this.onSelect, }); final String term; final bool isLoading; final Future Function(String userId) onSelect; @override Widget build(BuildContext context, WidgetRef ref) { final resultsAsync = ref.watch(searchUsersProvider(term)); final theme = Theme.of(context); final client = ref.watch(matrixClientProvider); return resultsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (err, _) => Padding( padding: const EdgeInsets.all(16), child: Text('Search failed: $err'), ), data: (profiles) { if (profiles.isEmpty) { return Padding( padding: const EdgeInsets.all(24), child: Text( 'No users found.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withAlpha(102), ), textAlign: TextAlign.center, ), ); } return ListView.builder( shrinkWrap: true, itemCount: profiles.length, itemBuilder: (context, index) { final profile = profiles[index]; final displayName = profile.displayName ?? profile.userId; return ListTile( leading: MatrixAvatar( name: displayName, avatarUrl: resolveMxcUrl(client, profile.avatarUrl), radius: 20, ), title: Text(displayName), subtitle: Text( profile.userId, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withAlpha(153), ), ), enabled: !isLoading, onTap: () => onSelect(profile.userId), ); }, ); }, ); } }