194 lines
6.5 KiB
Dart
194 lines
6.5 KiB
Dart
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,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|