import 'package:flutter/foundation.dart'; import '../../../../api/marianumcloud/search/search_files.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; import '../../../../utils/debouncer.dart'; import 'local_cache_search.dart'; /// Holds the live state of a Files-search session: current query, the latest /// local-cache hits (synchronous), the latest server hits (asynchronous, /// debounced), and loading/error flags. Notifies listeners whenever any of /// these change so the UI can rebuild incrementally as results stream in. class FilesSearchController extends ChangeNotifier { FilesSearchController({List? initialPathScope}) : _pathScope = List.from(initialPathScope ?? const []); static const Duration _serverDebounce = Duration(seconds: 1); final String _debounceTag = 'files-search-${DateTime.now().microsecondsSinceEpoch}'; final SearchFiles _api = SearchFiles(); String _query = ''; List _pathScope; List _cacheResults = const []; List _serverResults = const []; bool _serverLoading = false; Object? _serverError; int _serverEpoch = 0; bool _disposed = false; /// Guards against the race where the search delegate is closed (and the /// controller disposed) while a debounced cache scan or server call is /// still in flight: their late `notifyListeners()` would otherwise throw /// on a disposed `ChangeNotifier`. void _safeNotify() { if (_disposed) return; notifyListeners(); } String get query => _query; List get pathScope => List.unmodifiable(_pathScope); bool get isScoped => _pathScope.isNotEmpty; List get cacheResults => _cacheResults; List get serverResults => _serverResults; bool get serverLoading => _serverLoading; Object? get serverError => _serverError; /// Combined, deduplicated result list (cache hits first, then any /// server-only hits) — handy for empty-state checks. Dedup key is the /// WebDAV path. List get combinedResults { if (_cacheResults.isEmpty) return _serverResults; if (_serverResults.isEmpty) return _cacheResults; final seen = {for (final f in _cacheResults) f.path}; return [ ..._cacheResults, ..._serverResults.where((f) => seen.add(f.path)), ]; } Future setQuery(String value) async { if (value == _query) return; _query = value; // Bumping the epoch up front invalidates any in-flight server call from // a previous query, so its late response cannot toggle `_serverLoading` // off while a fresh search is queued behind the debounce. final epoch = ++_serverEpoch; if (_query.trim().isEmpty) { Debouncer.cancel(_debounceTag); _cacheResults = const []; _serverResults = const []; _serverLoading = false; _serverError = null; _safeNotify(); return; } // Show loading immediately — even before the (typically fast) cache // scan resolves — so the indicator is visible the moment the user // starts typing rather than after the first await hop. _serverLoading = true; _serverError = null; _safeNotify(); final cacheHits = await searchLocalCaches(_query, pathScope: _pathScope); if (epoch != _serverEpoch) return; _cacheResults = cacheHits; _safeNotify(); _scheduleServerCall(); } /// Drops the path filter and re-runs the current search globally. Used by /// the empty-state "Im Hauptverzeichnis suchen" button. Future searchEverywhere() async { if (!isScoped) return; _pathScope = const []; final epoch = ++_serverEpoch; if (_query.trim().isEmpty) { _safeNotify(); return; } _serverLoading = true; _serverError = null; _safeNotify(); final cacheHits = await searchLocalCaches(_query); if (epoch != _serverEpoch) return; _cacheResults = cacheHits; _safeNotify(); _scheduleServerCall(); } /// Re-runs the current server query immediately, bypassing the debounce. /// Wired to the `LoadableStateErrorScreen` "Erneut versuchen" button. void retry() { if (_query.trim().isEmpty) return; ++_serverEpoch; Debouncer.cancel(_debounceTag); _serverLoading = true; _serverError = null; _safeNotify(); _runServerCall(); } void _scheduleServerCall() { Debouncer.debounce(_debounceTag, _serverDebounce, _runServerCall); } Future _runServerCall() async { final epoch = _serverEpoch; final term = _query; final scopePrefix = _pathScope.isEmpty ? '' : '${_pathScope.join('/')}/'; try { final response = await _api.run(term: term); if (epoch != _serverEpoch) return; _serverResults = response.entries .map((e) => e.toCacheable()) .whereType() .where((f) => scopePrefix.isEmpty || f.path.startsWith(scopePrefix)) .toList(); _serverLoading = false; _serverError = null; _safeNotify(); } on Object catch (e) { if (epoch != _serverEpoch) return; _serverResults = const []; _serverLoading = false; _serverError = e; _safeNotify(); } } @override void dispose() { _disposed = true; Debouncer.cancel(_debounceTag); super.dispose(); } }