diff --git a/lib/api/marianumcloud/search/search_files.dart b/lib/api/marianumcloud/search/search_files.dart new file mode 100644 index 0000000..0d2c382 --- /dev/null +++ b/lib/api/marianumcloud/search/search_files.dart @@ -0,0 +1,36 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '../nextcloud_ocs.dart'; +import 'search_files_response.dart'; + +/// Wraps the Nextcloud OCS Search Provider API for the `files` provider. +/// Endpoint: `/ocs/v2.php/search/providers/files/search`. +class SearchFiles { + Future run({ + required String term, + int limit = 50, + int? cursor, + }) async { + final endpoint = NextcloudOcs.uri( + 'search/providers/files/search', + queryParameters: { + 'term': term, + 'limit': limit.toString(), + if (cursor != null) 'cursor': cursor.toString(), + }, + ); + final response = await http.get(endpoint, headers: NextcloudOcs.headers()); + if (response.statusCode != HttpStatus.ok) { + throw Exception( + 'Files search failed with ${response.statusCode}: ${response.body}', + ); + } + final decoded = jsonDecode(response.body) as Map; + final ocs = decoded['ocs'] as Map; + final data = ocs['data'] as Map; + return SearchFilesResponse.fromJson(data); + } +} diff --git a/lib/api/marianumcloud/search/search_files_response.dart b/lib/api/marianumcloud/search/search_files_response.dart new file mode 100644 index 0000000..d9b3d72 --- /dev/null +++ b/lib/api/marianumcloud/search/search_files_response.dart @@ -0,0 +1,91 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../webdav/queries/list_files/cacheable_file.dart'; + +part 'search_files_response.g.dart'; + +/// Subset of the OCS Search Provider API response we actually consume. +/// The provider (`files`) returns one object per match plus pagination state. +@JsonSerializable(explicitToJson: true) +class SearchFilesResponse { + final String name; + final bool isPaginated; + final int? cursor; + final List entries; + + SearchFilesResponse({ + required this.name, + required this.isPaginated, + required this.cursor, + required this.entries, + }); + + factory SearchFilesResponse.fromJson(Map json) => + _$SearchFilesResponseFromJson(json); + Map toJson() => _$SearchFilesResponseToJson(this); +} + +@JsonSerializable() +class SearchFilesEntry { + final String title; + final String? subline; + final String? icon; + final String? resourceUrl; + final Map? attributes; + + SearchFilesEntry({ + required this.title, + this.subline, + this.icon, + this.resourceUrl, + this.attributes, + }); + + factory SearchFilesEntry.fromJson(Map json) => + _$SearchFilesEntryFromJson(json); + Map toJson() => _$SearchFilesEntryToJson(this); + + /// Heuristic — the files provider sets icon classes containing "folder" for + /// directories. Falls back to false when missing or unrecognised. + bool get isDirectory => (icon ?? '').toLowerCase().contains('folder'); + + String? _stringAttribute(String key) { + final raw = attributes?[key]; + return raw is String && raw.isNotEmpty ? raw : null; + } + + String? _dirFromResourceUrl() { + final url = resourceUrl; + if (url == null) return null; + return Uri.tryParse(url)?.queryParameters['dir']; + } + + /// Reconstructs the WebDAV-relative path used elsewhere (matching + /// [CacheableFile.path] — no leading slash, trailing slash for + /// directories). Prefers the explicit `path` attribute set by Nextcloud's + /// files search provider (28+); falls back to the `dir` query parameter + /// in [resourceUrl]. Returns `null` when neither is available — `subline` + /// is intentionally **not** parsed because it is localized UI text + /// ("in {folder}"), not a path, and using it produced bogus duplicate + /// folder headers like "/in Alte-Notebooks". + String? get webdavPath { + final attrPath = _stringAttribute('path'); + if (attrPath != null) { + final stripped = attrPath.replaceAll(RegExp(r'^/+|/+$'), ''); + return isDirectory ? '$stripped/' : stripped; + } + final dir = _dirFromResourceUrl(); + if (dir != null) { + final stripped = dir.replaceAll(RegExp(r'^/+|/+$'), ''); + final base = stripped.isEmpty ? title : '$stripped/$title'; + return isDirectory ? '$base/' : base; + } + return null; + } + + CacheableFile? toCacheable() { + final path = webdavPath; + if (path == null) return null; + return CacheableFile(path: path, isDirectory: isDirectory, name: title); + } +} diff --git a/lib/api/marianumcloud/search/search_files_response.g.dart b/lib/api/marianumcloud/search/search_files_response.g.dart new file mode 100644 index 0000000..64dbe45 --- /dev/null +++ b/lib/api/marianumcloud/search/search_files_response.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search_files_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SearchFilesResponse _$SearchFilesResponseFromJson(Map json) => + SearchFilesResponse( + name: json['name'] as String, + isPaginated: json['isPaginated'] as bool, + cursor: (json['cursor'] as num?)?.toInt(), + entries: (json['entries'] as List) + .map((e) => SearchFilesEntry.fromJson(e as Map)) + .toList(), + ); + +Map _$SearchFilesResponseToJson( + SearchFilesResponse instance, +) => { + 'name': instance.name, + 'isPaginated': instance.isPaginated, + 'cursor': instance.cursor, + 'entries': instance.entries.map((e) => e.toJson()).toList(), +}; + +SearchFilesEntry _$SearchFilesEntryFromJson(Map json) => + SearchFilesEntry( + title: json['title'] as String, + subline: json['subline'] as String?, + icon: json['icon'] as String?, + resourceUrl: json['resourceUrl'] as String?, + attributes: json['attributes'] as Map?, + ); + +Map _$SearchFilesEntryToJson(SearchFilesEntry instance) => + { + 'title': instance.title, + 'subline': instance.subline, + 'icon': instance.icon, + 'resourceUrl': instance.resourceUrl, + 'attributes': instance.attributes, + }; diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index b860b7e..54943d2 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -14,6 +14,7 @@ import '../../../utils/cache_invalidation_bus.dart'; import '../../../widget/placeholder_view.dart'; import 'data/sort_options.dart'; import 'files_upload_dialog.dart'; +import 'search/files_search_delegate.dart'; import 'widgets/add_file_menu.dart'; import 'widgets/clipboard_banner.dart'; import 'widgets/file_element.dart'; @@ -101,6 +102,15 @@ class _FilesViewState extends State<_FilesView> { appBar: AppBar( title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'), actions: [ + IconButton( + tooltip: 'Suchen', + icon: const Icon(Icons.search), + onPressed: () async { + final delegate = FilesSearchDelegate(pathScope: widget.path); + await showSearch(context: context, delegate: delegate); + delegate.disposeController(); + }, + ), FilesSortActions( currentSort: currentSort, ascending: currentSortDirection, diff --git a/lib/view/pages/files/search/files_search_controller.dart b/lib/view/pages/files/search/files_search_controller.dart new file mode 100644 index 0000000..ed2fee3 --- /dev/null +++ b/lib/view/pages/files/search/files_search_controller.dart @@ -0,0 +1,146 @@ +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; + + 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; + notifyListeners(); + 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; + notifyListeners(); + + final cacheHits = await searchLocalCaches(_query, pathScope: _pathScope); + if (epoch != _serverEpoch) return; + _cacheResults = cacheHits; + notifyListeners(); + _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) { + notifyListeners(); + return; + } + _serverLoading = true; + _serverError = null; + notifyListeners(); + + final cacheHits = await searchLocalCaches(_query); + if (epoch != _serverEpoch) return; + _cacheResults = cacheHits; + notifyListeners(); + _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; + notifyListeners(); + _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; + notifyListeners(); + } on Object catch (e) { + if (epoch != _serverEpoch) return; + _serverResults = const []; + _serverLoading = false; + _serverError = e; + notifyListeners(); + } + } + + @override + void dispose() { + Debouncer.cancel(_debounceTag); + super.dispose(); + } +} diff --git a/lib/view/pages/files/search/files_search_delegate.dart b/lib/view/pages/files/search/files_search_delegate.dart new file mode 100644 index 0000000..c909a7b --- /dev/null +++ b/lib/view/pages/files/search/files_search_delegate.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import 'files_search_controller.dart'; +import 'files_search_results.dart'; + +/// Material `SearchDelegate` for the Files module — opens via the magnifier +/// in `FilesPage`'s AppBar (mirroring `SearchMarianumMessages`). Owns one +/// [FilesSearchController]; cache + server hits stream into the result list +/// as the user types. +class FilesSearchDelegate extends SearchDelegate { + final FilesSearchController _controller; + + FilesSearchDelegate({required List pathScope}) + : _controller = FilesSearchController(initialPathScope: pathScope), + super(searchFieldLabel: 'Dateien suchen'); + + /// Must be called by the host widget after `showSearch` returns so the + /// controller's listeners and pending debounce timers are released. + void disposeController() => _controller.dispose(); + + @override + List? buildActions(BuildContext context) => [ + if (query.isNotEmpty) + IconButton( + tooltip: 'Suche leeren', + icon: const Icon(Icons.clear), + onPressed: () { + query = ''; + }, + ), + ]; + + @override + Widget? buildLeading(BuildContext context) => IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => close(context, null), + ); + + @override + Widget buildResults(BuildContext context) { + _controller.setQuery(query); + return FilesSearchResults( + controller: _controller, + onResultTap: () => close(context, null), + ); + } + + @override + Widget buildSuggestions(BuildContext context) { + _controller.setQuery(query); + return FilesSearchResults( + controller: _controller, + onResultTap: () => close(context, null), + ); + } +} diff --git a/lib/view/pages/files/search/files_search_results.dart b/lib/view/pages/files/search/files_search_results.dart new file mode 100644 index 0000000..ff9b611 --- /dev/null +++ b/lib/view/pages/files/search/files_search_results.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/errors/error_mapper.dart'; +import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../../routing/app_routes.dart'; +import '../../../../state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart'; +import '../../../../state/app/infrastructure/loadable_state/bloc/loadable_state_state.dart'; +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart'; +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart'; +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart'; +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart'; +import '../../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; +import '../../../../widget/placeholder_view.dart'; +import '../widgets/file_element.dart'; +import 'files_search_controller.dart'; + +/// Renders the live state of a [FilesSearchController]. Wraps everything in a +/// `LoadableStateBloc` module so the search reuses the standard primary / +/// background loading and error views from the rest of the app. +class FilesSearchResults extends StatelessWidget { + final FilesSearchController controller; + final VoidCallback? onResultTap; + + const FilesSearchResults({ + required this.controller, + this.onResultTap, + super.key, + }); + + @override + Widget build(BuildContext context) => + BlocModule( + create: (_) => LoadableStateBloc(), + child: (context, bloc, _) { + bloc.reFetch = controller.retry; + return ListenableBuilder( + listenable: controller, + builder: (context, _) => _buildBody(context), + ); + }, + ); + + Widget _buildBody(BuildContext context) { + if (controller.query.trim().isEmpty) { + return const PlaceholderView( + icon: Icons.search, + text: 'Tippen, um in Dateien zu suchen.', + ); + } + final combined = controller.combinedResults; + final hasContent = combined.isNotEmpty; + final hasError = controller.serverError != null; + final isLoading = controller.serverLoading; + + final showPrimaryLoading = isLoading && !hasContent; + final showBackgroundLoading = isLoading && hasContent; + final showErrorScreen = hasError && !hasContent && !isLoading; + final showErrorBar = hasError && hasContent; + final showEmpty = !hasContent && !hasError && !isLoading; + + final errorMessage = hasError ? errorToUserMessage(controller.serverError) : null; + + return Column( + children: [ + LoadableStateErrorBar( + visible: showErrorBar, + hasContent: hasContent, + message: errorMessage, + ), + // Background loading sits *outside* the result Stack so the linear + // progress bar is not painted over by the opaque ListView/ListTiles + // when cache hits are already on screen and the server is still + // working. The widget collapses to zero height when invisible. + LoadableStateBackgroundLoading(visible: showBackgroundLoading), + Expanded( + child: Stack( + children: [ + LoadableStatePrimaryLoading(visible: showPrimaryLoading), + LoadableStateErrorScreen( + visible: showErrorScreen, + message: errorMessage, + ), + if (showEmpty) _emptyState(context), + if (hasContent) _resultList(context, combined), + ], + ), + ), + ], + ); + } + + Widget _emptyState(BuildContext context) => PlaceholderView( + icon: Icons.search_off_outlined, + text: 'Keine Treffer gefunden.', + button: controller.isScoped + ? FilledButton.icon( + onPressed: controller.searchEverywhere, + icon: const Icon(Icons.travel_explore), + label: const Text('Im Hauptverzeichnis suchen'), + ) + : null, + ); + + Widget _resultList(BuildContext context, List combined) { + final groups = _groupByParent(combined); + final orderedKeys = groups.keys.toList()..sort(); + final items = []; + for (final folder in orderedKeys) { + final segments = _segmentsOf(folder); + items.add( + _FolderHeader( + folder: folder, + onOpen: () { + onResultTap?.call(); + AppRoutes.openFolder(context, segments); + }, + ), + ); + for (final file in groups[folder]!) { + items.add( + FileElement( + file, + segments, + controller.retry, + highlight: controller.query, + ), + ); + } + } + return ListView(padding: EdgeInsets.zero, children: items); + } + + Map> _groupByParent(List files) { + final map = >{}; + for (final file in files) { + map.putIfAbsent(_parentOf(file), () => []).add(file); + } + return map; + } + + String _parentOf(CacheableFile file) { + final stripped = file.path.replaceAll(RegExp(r'^/+|/+$'), ''); + final segments = stripped.split('/'); + if (segments.length <= 1) return '/'; + segments.removeLast(); + return '/${segments.join('/')}'; + } + + List _segmentsOf(String folder) { + final stripped = folder.replaceAll(RegExp(r'^/+|/+$'), ''); + if (stripped.isEmpty) return const []; + return stripped.split('/'); + } +} + +class _FolderHeader extends StatelessWidget { + final String folder; + final VoidCallback onOpen; + const _FolderHeader({required this.folder, required this.onOpen}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + height: 38, + color: theme.colorScheme.surfaceContainer, + padding: const EdgeInsets.only(left: 16), + child: Row( + children: [ + Expanded( + child: Text( + folder, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + tooltip: 'Ordner öffnen', + iconSize: 20, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.folder_open_outlined), + onPressed: onOpen, + ), + ], + ), + ); + } +} diff --git a/lib/view/pages/files/search/local_cache_search.dart b/lib/view/pages/files/search/local_cache_search.dart new file mode 100644 index 0000000..45a14e4 --- /dev/null +++ b/lib/view/pages/files/search/local_cache_search.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; + +import 'package:localstore/localstore.dart'; + +import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; +import '../../../../api/request_cache.dart'; + +/// Document key prefix used by `ListFilesCache._documentId`. +const String _folderCachePrefix = 'wd-folder-'; + +/// Scans every cached folder listing in Localstore and returns files/folders +/// whose name contains [query] (case-insensitive). +/// +/// [pathScope] restricts results to entries whose WebDAV path starts with +/// the given folder. Pass an empty list (or null) to search globally. +/// +/// [docs] is an injection seam for tests — production callers leave it null +/// so the helper reads from the real Localstore. +Future> searchLocalCaches( + String query, { + List? pathScope, + Map? docs, +}) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) return const []; + final needle = trimmed.toLowerCase(); + final scopePrefix = pathScope == null || pathScope.isEmpty + ? '' + : '${pathScope.join('/')}/'; + + final raw = + docs ?? + await Localstore.instance.collection(RequestCache.collection).get(); + if (raw == null || raw.isEmpty) return const []; + + final results = {}; + for (final entry in raw.entries) { + final docKey = entry.key.split('/').last; + if (!docKey.startsWith(_folderCachePrefix)) continue; + + final value = entry.value; + if (value is! Map) continue; + final json = value['json']; + if (json is! String) continue; + + final ListFilesResponse listing; + try { + listing = ListFilesResponse.fromJson( + jsonDecode(json) as Map, + ); + } on Object { + continue; + } + + for (final file in listing.files) { + if (!file.name.toLowerCase().contains(needle)) continue; + if (scopePrefix.isNotEmpty && !file.path.startsWith(scopePrefix)) { + continue; + } + results[file.path] ??= file; + } + } + return results.values.toList(); +} diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index c573ae8..da3f107 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -14,13 +14,25 @@ import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/info_dialog.dart'; +import '../../talk/widgets/highlighted_linkify.dart'; import 'file_details_sheet.dart'; class FileElement extends StatefulWidget { final CacheableFile file; final List path; final void Function() refetch; - const FileElement(this.file, this.path, this.refetch, {super.key}); + + /// When non-null, occurrences of this string in the file name are visually + /// highlighted in the tile title. Used by the Files search delegate. + final String? highlight; + + const FileElement( + this.file, + this.path, + this.refetch, { + this.highlight, + super.key, + }); @override State createState() => _FileElementState(); @@ -118,7 +130,7 @@ class _FileElementState extends State { ); } - Widget _subtitle() { + Widget? _subtitle() { final status = _job?.status.value; if (status is DownloadInProgress) { return Row( @@ -135,10 +147,16 @@ class _FileElementState extends State { ], ); } - final modified = widget.file.modifiedAt ?? DateTime.now(); - return widget.file.isDirectory - ? Text('geändert ${modified.formatRelative()}') - : Text('${filesize(widget.file.size)}, ${modified.formatRelative()}'); + final modified = widget.file.modifiedAt; + final size = widget.file.size; + if (widget.file.isDirectory) { + if (modified == null) return null; + return Text('geändert ${modified.formatRelative()}'); + } + if (size == null && modified == null) return null; + if (size == null) return Text(modified!.formatRelative()); + if (modified == null) return Text(filesize(size)); + return Text('${filesize(size)}, ${modified.formatRelative()}'); } void _onTap() { @@ -328,12 +346,36 @@ class _FileElementState extends State { ); } + Widget _title(BuildContext context) { + final base = + Theme.of(context).textTheme.bodyLarge ?? + DefaultTextStyle.of(context).style; + if (widget.highlight == null || widget.highlight!.trim().isEmpty) { + return Text( + widget.file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ); + } + return Text.rich( + TextSpan( + children: buildHighlightedSpans( + text: widget.file.name, + query: widget.highlight, + baseStyle: base, + ), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ); + } + @override Widget build(BuildContext context) => ListTile( leading: CenteredLeading( Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined), ), - title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis), + title: _title(context), subtitle: _subtitle(), trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null), onTap: _onTap, diff --git a/test/view/files/local_cache_search_test.dart b/test/view/files/local_cache_search_test.dart new file mode 100644 index 0000000..c1a0868 --- /dev/null +++ b/test/view/files/local_cache_search_test.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; +import 'package:marianum_mobile/view/pages/files/search/local_cache_search.dart'; + +CacheableFile _file({ + required String path, + required String name, + bool isDirectory = false, +}) => CacheableFile(path: path, isDirectory: isDirectory, name: name); + +Map _doc(ListFilesResponse listing) => { + 'json': jsonEncode(listing.toJson()), + 'lastupdate': 0, +}; + +void main() { + group('searchLocalCaches', () { + final root = ListFilesResponse({ + _file(path: 'Documents/', name: 'Documents', isDirectory: true), + _file(path: 'Photos/', name: 'Photos', isDirectory: true), + _file(path: 'Reports.pdf', name: 'Reports.pdf'), + }); + final documents = ListFilesResponse({ + _file(path: 'Documents/Tax-Report.pdf', name: 'Tax-Report.pdf'), + _file(path: 'Documents/Notes.txt', name: 'Notes.txt'), + }); + final docs = { + '/MarianumMobile/wd-folder-aaa': _doc(root), + '/MarianumMobile/wd-folder-bbb': _doc(documents), + '/MarianumMobile/get-room-ccc': {'json': '{}', 'lastupdate': 0}, + }; + + test('matches by name case-insensitively across all caches', () async { + final hits = await searchLocalCaches('report', docs: docs); + final paths = hits.map((f) => f.path).toSet(); + expect(paths, {'Reports.pdf', 'Documents/Tax-Report.pdf'}); + }); + + test('returns empty list for empty query', () async { + expect(await searchLocalCaches(' ', docs: docs), isEmpty); + }); + + test('respects pathScope prefix', () async { + final hits = await searchLocalCaches( + 'report', + pathScope: ['Documents'], + docs: docs, + ); + expect(hits.map((f) => f.path), ['Documents/Tax-Report.pdf']); + }); + + test('ignores non-folder cache documents', () async { + final hits = await searchLocalCaches('anything', docs: docs); + // Only documents starting with `wd-folder-` are scanned. The unrelated + // `get-room-ccc` doc must not crash the helper. + expect(hits, isEmpty); + }); + + test('deduplicates entries that appear in multiple cached folders', + () async { + final shared = _file( + path: 'Documents/Tax-Report.pdf', + name: 'Tax-Report.pdf', + ); + final dedupRoot = ListFilesResponse({shared}); + final dedupDocs = { + '/MarianumMobile/wd-folder-aaa': _doc(dedupRoot), + '/MarianumMobile/wd-folder-bbb': _doc(dedupRoot), + }; + final hits = await searchLocalCaches('tax', docs: dedupDocs); + expect(hits, hasLength(1)); + }); + }); +}