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, ), ], ), ); } }