chat search now lists local and server-side hits like the file search, and jumping to old messages pages in history with a progress banner and keeps the hit marked

This commit is contained in:
MineTec committed 2026-10-28 22:40:21 +01:00
1 parent fcfd21bb4d
commit b9cb6a8af1
13 files changed
+926 -322

No files matched your search

@@ -0,0 +1,197 @@
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<ChatSearchHit> onResultTap;
const ChatSearchResults({
required this.controller,
required 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 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<ChatSearchHit> hits) {
final showFooter = controller.canLoadMore || controller.loadingMore;
return NotificationListener<ScrollNotification>(
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,
);
}
}