import 'package:flutter/material.dart'; import '../../../../api/errors/error_mapper.dart'; import '../../../../extensions/date_time.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 '../../../../widget/user_avatar.dart'; import '../data/chat_message_matcher.dart'; import '../widgets/highlighted_linkify.dart'; import 'chat_search_controller.dart'; /// Renders a [ChatSearchController] with the same loading / error views as /// `FilesSearchResults`. class ChatSearchResults extends StatelessWidget { final ChatSearchController controller; final ValueChanged onResultTap; const ChatSearchResults({ required this.controller, required 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 im Chat 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, ), LoadableStateBackgroundLoading(visible: showBackgroundLoading), Expanded( child: Stack( children: [ LoadableStatePrimaryLoading(visible: showPrimaryLoading), LoadableStateErrorScreen( visible: showErrorScreen, message: errorMessage, ), if (showEmpty) const PlaceholderView( icon: Icons.search_off_outlined, text: 'Keine Treffer gefunden.', ), if (hasContent) _resultList(combined), ], ), ), ], ); } Widget _resultList(List hits) { final showFooter = controller.canLoadMore || controller.loadingMore; return NotificationListener( onNotification: (n) { if (n.metrics.extentAfter < 400) controller.loadMore(); return false; }, child: ListView.builder( padding: EdgeInsets.zero, itemCount: hits.length + (showFooter ? 1 : 0), itemBuilder: (context, index) { if (index == hits.length) { // A short first page never scrolls, so the footer also triggers // the next page when it gets built. WidgetsBinding.instance.addPostFrameCallback( (_) => controller.loadMore(), ); return const Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()), ); } final hit = hits[index]; return _ChatSearchTile( key: ValueKey(hit.messageId), hit: hit, query: controller.query, onTap: () => onResultTap(hit), ); }, ), ); } } class _ChatSearchTile extends StatelessWidget { final ChatSearchHit hit; final String query; final VoidCallback onTap; const _ChatSearchTile({ required this.hit, required this.query, required this.onTap, super.key, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final base = theme.textTheme.bodyMedium ?? DefaultTextStyle.of(context).style; final actorId = hit.actorId; final sentAt = hit.sentAt; return ListTile( leading: actorId != null ? UserAvatar( id: actorId, size: 20, semanticLabel: hit.actorDisplayName, ) : const CircleAvatar(radius: 20, child: Icon(Icons.info_outline)), title: Row( children: [ Expanded( child: Text.rich( TextSpan( children: buildHighlightedSpans( text: hit.actorDisplayName ?? 'System', query: query, baseStyle: theme.textTheme.titleSmall ?? base, ), ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), if (sentAt != null) Padding( padding: const EdgeInsets.only(left: 8), child: Text( sentAt.year == DateTime.now().year ? sentAt.formatDateShortHm() : sentAt.formatDate(), style: theme.textTheme.bodySmall, ), ), ], ), subtitle: Text.rich( TextSpan( children: buildHighlightedSpans( text: hit.text, query: query, baseStyle: base.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ), maxLines: 3, overflow: TextOverflow.ellipsis, ), onTap: onTap, ); } }