import 'dart:async'; import 'package:flutter/material.dart'; import '../../../api/errors/error_mapper.dart'; import '../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../api/marianumconnect/queries/user_search/user_search.dart'; import '../../../api/marianumconnect/queries/user_search/user_search_response.dart'; import '../../../widget/app_progress_indicator.dart'; import 'widgets/chat_tile.dart'; import 'widgets/user_search_tile.dart'; class SearchChat extends SearchDelegate { List chats; final void Function(GetRoomResponseObject room)? onTapOverride; /// When set, results are extended with a clearly separated "start a new chat" /// section fed by the MarianumConnect user search. Tapping a person invokes /// this callback with their username and display name. Left null (e.g. the /// share picker) the search stays limited to existing chats. final void Function(String username, String displayName)? onStartChat; SearchChat(this.chats, {this.onTapOverride, this.onStartChat}); @override List? buildActions(BuildContext context) => [ if (query.isNotEmpty) IconButton( tooltip: 'Leeren', onPressed: () => query = '', icon: const Icon(Icons.clear), ), ]; @override Widget? buildLeading(BuildContext context) => null; @override Widget buildResults(BuildContext context) => _SearchChatResults( chats: chats, query: query, onTapOverride: onTapOverride, onStartChat: onStartChat == null ? null : (username, displayName) { close(context, null); onStartChat!(username, displayName); }, ); @override Widget buildSuggestions(BuildContext context) => buildResults(context); } class _SearchChatResults extends StatefulWidget { final List chats; final String query; final void Function(GetRoomResponseObject room)? onTapOverride; final void Function(String username, String displayName)? onStartChat; const _SearchChatResults({ required this.chats, required this.query, required this.onTapOverride, required this.onStartChat, }); @override State<_SearchChatResults> createState() => _SearchChatResultsState(); } class _SearchChatResultsState extends State<_SearchChatResults> { static const _debounceDelay = Duration(milliseconds: 350); static const _minQueryLength = 2; static const _loadingIndicator = Padding( padding: EdgeInsets.all(16), child: Center(child: AppProgressIndicator.medium()), ); Timer? _debounce; // Monotonic id so a slow response for an earlier query can never overwrite // the results of a newer one. int _requestId = 0; // null until the first response for the current query settles; previous // results stay put while the next query is loading, so the list does not // flash to a spinner on every keystroke. List? _users; Object? _error; @override void initState() { super.initState(); _scheduleUserSearch(widget.query); } @override void didUpdateWidget(_SearchChatResults oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.query != widget.query) _scheduleUserSearch(widget.query); } @override void dispose() { _debounce?.cancel(); super.dispose(); } void _scheduleUserSearch(String query) { if (widget.onStartChat == null) return; _debounce?.cancel(); final trimmed = query.trim(); if (trimmed.length < _minQueryLength) { // Invalidate any in-flight response and drop the section content. _requestId++; setState(() { _users = null; _error = null; }); return; } _debounce = Timer(_debounceDelay, () => _runSearch(trimmed)); } Future _runSearch(String query) async { final id = ++_requestId; try { final response = await UserSearch().run(query); if (!mounted || id != _requestId) return; setState(() { _users = response.result; _error = null; }); } catch (e) { if (!mounted || id != _requestId) return; setState(() => _error = e); } } @override Widget build(BuildContext context) { final query = widget.query.toLowerCase(); final chatMatches = widget.chats .where( (e) => e.displayName.toString().toLowerCase().contains(query) || e.name.toString().toLowerCase().contains(query), ) .toList() ..sort((a, b) => b.lastActivity.compareTo(a.lastActivity)); return ListView( children: [ ...chatMatches.map( (item) => ChatTile( data: item, disableContextActions: true, onTapOverride: widget.onTapOverride, ), ), if (widget.onStartChat != null) _buildUserSection(context), ], ); } Widget _buildUserSection(BuildContext context) { if (widget.query.trim().length < _minQueryLength) { return const SizedBox.shrink(); } // Usernames that already have a direct chat — they are listed above, so // the "start new chat" section skips them. final existing = { for (final c in widget.chats) if (c.type == GetRoomResponseObjectConversationType.oneToOne) c.name, }; final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Divider(height: 32), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Row( children: [ Icon( Icons.person_add_alt_1, size: 20, color: theme.colorScheme.primary, ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Neuen Chat starten', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), Text( 'Personen aus der Suche', style: theme.textTheme.bodySmall?.copyWith( color: theme.hintColor, ), ), ], ), ), ], ), ), _buildUserResults(context, existing), ], ); } Widget _buildUserResults(BuildContext context, Set existing) { if (_error != null) return _hint(context, errorToUserMessage(_error)); final all = _users; if (all == null) return _loadingIndicator; final users = all.where((u) => !existing.contains(u.username)).toList(); if (users.isEmpty) { return _hint(context, 'Keine weiteren Personen gefunden'); } return Column( children: users .map( (user) => UserSearchTile( user: user, onTap: () => widget.onStartChat!( user.username, '${user.firstName} ${user.lastName}', ), ), ) .toList(), ); } Widget _hint(BuildContext context, String text) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), child: Text( text, style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor), ), ); } }