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,129 @@
import 'package:flutter/foundation.dart';
import '../../../../api/demo/demo_mode.dart';
import '../../../../api/marianumcloud/search/search_chat_messages.dart';
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../utils/debouncer.dart';
import '../data/chat_message_matcher.dart';
/// Live state of a search inside one chat, mirroring `FilesSearchController`:
/// hits from the loaded history appear immediately, the server-side search
/// over the full history follows debounced and can be paged further.
class ChatSearchController extends ChangeNotifier {
ChatSearchController({
required this.chatToken,
required GetChatResponse? Function() loadedMessages,
}) : _loadedMessages = loadedMessages;
final String chatToken;
final GetChatResponse? Function() _loadedMessages;
static const Duration _serverDebounce = Duration(seconds: 1);
final String _debounceTag =
'chat-search-${DateTime.now().microsecondsSinceEpoch}';
final SearchChatMessages _api = SearchChatMessages();
String _query = '';
List<ChatSearchHit> _localResults = const [];
List<ChatSearchHit> _serverResults = const [];
Object? _serverCursor;
bool _serverLoading = false;
bool _loadingMore = false;
Object? _serverError;
int _serverEpoch = 0;
bool _disposed = false;
void _safeNotify() {
if (_disposed) return;
notifyListeners();
}
String get query => _query;
bool get serverLoading => _serverLoading;
bool get loadingMore => _loadingMore;
bool get canLoadMore => _serverCursor != null;
Object? get serverError => _serverError;
List<ChatSearchHit> get combinedResults =>
ChatMessageMatcher.merge(_localResults, _serverResults);
void setQuery(String value) {
if (value == _query) return;
_query = value;
// Invalidates in-flight server calls of the previous query.
++_serverEpoch;
_serverResults = const [];
_serverCursor = null;
_loadingMore = false;
_serverError = null;
if (_query.trim().isEmpty) {
Debouncer.cancel(_debounceTag);
_localResults = const [];
_serverLoading = false;
_safeNotify();
return;
}
final loaded = _loadedMessages();
_localResults = loaded == null
? const []
: ChatMessageMatcher.findMatches(loaded, _query);
// Demo sessions must not hit the real Nextcloud (see DemoHttpClient).
_serverLoading = !DemoMode.active;
_safeNotify();
if (_serverLoading) {
Debouncer.debounce(_debounceTag, _serverDebounce, _runServerCall);
}
}
void retry() {
if (_query.trim().isEmpty || DemoMode.active) return;
++_serverEpoch;
Debouncer.cancel(_debounceTag);
_serverResults = const [];
_serverCursor = null;
_loadingMore = false;
_serverLoading = true;
_serverError = null;
_safeNotify();
_runServerCall();
}
void loadMore() {
if (_serverCursor == null || _serverLoading || _loadingMore) return;
_loadingMore = true;
_safeNotify();
_runServerCall(cursor: _serverCursor);
}
Future<void> _runServerCall({Object? cursor}) async {
final epoch = _serverEpoch;
try {
final response = await _api.run(
chatToken: chatToken,
term: _query.trim(),
cursor: cursor,
);
if (epoch != _serverEpoch) return;
_serverResults = [
..._serverResults,
...response.entries.map(ChatSearchHit.fromServer),
];
_serverCursor = response.cursor;
_serverError = null;
} on Object catch (e) {
if (epoch != _serverEpoch) return;
_serverCursor = null;
_serverError = e;
}
_serverLoading = false;
_loadingMore = false;
_safeNotify();
}
@override
void dispose() {
_disposed = true;
Debouncer.cancel(_debounceTag);
super.dispose();
}
}
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import '../data/chat_message_matcher.dart';
import 'chat_search_controller.dart';
import 'chat_search_results.dart';
/// Search inside one chat, built like `FilesSearchDelegate`. Closes with the
/// tapped hit so the chat can jump there.
class ChatSearchDelegate extends SearchDelegate<ChatSearchHit?> {
final ChatSearchController _controller;
ChatSearchDelegate(this._controller)
: super(searchFieldLabel: 'In Chat suchen');
/// Must be called by the host widget after `showSearch` returns so the
/// controller's listeners and pending debounce timers are released.
void disposeController() => _controller.dispose();
@override
List<Widget>? buildActions(BuildContext context) => [
if (query.isNotEmpty)
IconButton(
tooltip: 'Suche leeren',
icon: const Icon(Icons.clear),
onPressed: () {
query = '';
},
),
];
@override
Widget? buildLeading(BuildContext context) => IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Zurück',
onPressed: () => close(context, null),
);
@override
Widget buildResults(BuildContext context) => buildSuggestions(context);
@override
Widget buildSuggestions(BuildContext context) {
_controller.setQuery(query);
return ChatSearchResults(
controller: _controller,
onResultTap: (hit) => close(context, hit),
);
}
}
@@ -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,
);
}
}