import 'package:flutter/foundation.dart'; import '../../../../api/demo/demo_mode.dart'; import '../../../../api/marianumcloud/search/search_chat_messages.dart'; import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../utils/debouncer.dart'; import '../data/chat_message_matcher.dart'; /// Live state of a search inside one chat, mirroring `FilesSearchController`: /// hits from the loaded history appear immediately, the server-side search /// over the full history follows debounced and can be paged further. class ChatSearchController extends ChangeNotifier { ChatSearchController({ required this.chatToken, required GetChatResponse? Function() loadedMessages, }) : _loadedMessages = loadedMessages; final String chatToken; final GetChatResponse? Function() _loadedMessages; static const Duration _serverDebounce = Duration(seconds: 1); final String _debounceTag = 'chat-search-${DateTime.now().microsecondsSinceEpoch}'; final SearchChatMessages _api = SearchChatMessages(); String _query = ''; List _localResults = const []; List _serverResults = const []; Object? _serverCursor; bool _serverLoading = false; bool _loadingMore = false; Object? _serverError; int _serverEpoch = 0; bool _disposed = false; void _safeNotify() { if (_disposed) return; notifyListeners(); } String get query => _query; bool get serverLoading => _serverLoading; bool get loadingMore => _loadingMore; bool get canLoadMore => _serverCursor != null; Object? get serverError => _serverError; List get combinedResults => ChatMessageMatcher.merge(_localResults, _serverResults); void setQuery(String value) { if (value == _query) return; _query = value; // Invalidates in-flight server calls of the previous query. ++_serverEpoch; _serverResults = const []; _serverCursor = null; _loadingMore = false; _serverError = null; if (_query.trim().isEmpty) { Debouncer.cancel(_debounceTag); _localResults = const []; _serverLoading = false; _safeNotify(); return; } final loaded = _loadedMessages(); _localResults = loaded == null ? const [] : ChatMessageMatcher.findMatches(loaded, _query); // Demo sessions must not hit the real Nextcloud (see DemoHttpClient). _serverLoading = !DemoMode.active; _safeNotify(); if (_serverLoading) { Debouncer.debounce(_debounceTag, _serverDebounce, _runServerCall); } } void retry() { if (_query.trim().isEmpty || DemoMode.active) return; ++_serverEpoch; Debouncer.cancel(_debounceTag); _serverResults = const []; _serverCursor = null; _loadingMore = false; _serverLoading = true; _serverError = null; _safeNotify(); _runServerCall(); } void loadMore() { if (_serverCursor == null || _serverLoading || _loadingMore) return; _loadingMore = true; _safeNotify(); _runServerCall(cursor: _serverCursor); } Future _runServerCall({Object? cursor}) async { final epoch = _serverEpoch; try { final response = await _api.run( chatToken: chatToken, term: _query.trim(), cursor: cursor, ); if (epoch != _serverEpoch) return; _serverResults = [ ..._serverResults, ...response.entries.map(ChatSearchHit.fromServer), ]; _serverCursor = response.cursor; _serverError = null; } on Object catch (e) { if (epoch != _serverEpoch) return; _serverCursor = null; _serverError = e; } _serverLoading = false; _loadingMore = false; _safeNotify(); } @override void dispose() { _disposed = true; Debouncer.cancel(_debounceTag); super.dispose(); } }