158 lines
5.2 KiB
Dart
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();
|
|
}
|
|
}
|