implemented file search with local cache and server-side support, added result highlighting, and integrated search delegate into files page

This commit is contained in:
2026-05-09 23:20:11 +02:00
parent 8e6b1877cc
commit 14090b96f4
10 changed files with 767 additions and 7 deletions
@@ -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<LoadableStateBloc, LoadableStateState>(
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<CacheableFile> combined) {
final groups = _groupByParent(combined);
final orderedKeys = groups.keys.toList()..sort();
final items = <Widget>[];
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<String, List<CacheableFile>> _groupByParent(List<CacheableFile> files) {
final map = <String, List<CacheableFile>>{};
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<String> _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,
),
],
),
);
}
}