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:
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user