Files
Client/lib/view/pages/files/search/files_search_controller.dart
T

158 lines
5.2 KiB
Dart

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<String>? initialPathScope})
: _pathScope = List<String>.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<String> _pathScope;
List<CacheableFile> _cacheResults = const [];
List<CacheableFile> _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;
_safeNotify();
}
String get query => _query;
List<String> get pathScope => List.unmodifiable(_pathScope);
bool get isScoped => _pathScope.isNotEmpty;
List<CacheableFile> get cacheResults => _cacheResults;
List<CacheableFile> 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<CacheableFile> get combinedResults {
if (_cacheResults.isEmpty) return _serverResults;
if (_serverResults.isEmpty) return _cacheResults;
final seen = <String>{for (final f in _cacheResults) f.path};
return [
..._cacheResults,
..._serverResults.where((f) => seen.add(f.path)),
];
}
Future<void> 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<void> 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<void> _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<CacheableFile>()
.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();
}
}