Compare commits

...

3 Commits

Author SHA1 Message Date
ac424bde36 💄 Optimize the ability to search 2025-10-10 00:50:17 +08:00
b43b70df3f 💄 Optimize the search message a step further 2025-10-10 00:43:49 +08:00
4321aa621a 💄 Optimize search message design 2025-10-10 00:38:43 +08:00
2 changed files with 276 additions and 102 deletions

View File

@@ -124,6 +124,7 @@ class MessagesNotifier extends _$MessagesNotifier {
}) async {
talker.log('Getting cached messages from offset $offset, take $take');
final List<LocalChatMessage> dbMessages;
if (searchQuery != null && searchQuery.isNotEmpty) {
dbMessages = await _database.searchMessages(
_roomId,
@@ -147,6 +148,13 @@ class MessagesNotifier extends _$MessagesNotifier {
filteredMessages.where((msg) => _hasLink(msg)).toList();
}
if (withAttachments == true) {
filteredMessages =
filteredMessages
.where((msg) => msg.toRemoteMessage().attachments.isNotEmpty)
.toList();
}
final dbLocalMessages = filteredMessages;
// Always ensure unique messages to prevent duplicate keys
@@ -778,18 +786,23 @@ class MessagesNotifier extends _$MessagesNotifier {
bool? withAttachments,
}) async {
final trimmedQuery = query.trim();
final hasFilters = [withLinks, withAttachments].any((e) => e == true);
if (trimmedQuery.isEmpty) {
if (trimmedQuery.isEmpty && !hasFilters) {
return [];
}
talker.log('Getting search results for query: $trimmedQuery');
talker.log(
'Getting search results for query: $trimmedQuery, filters: links=$withLinks, attachments=$withAttachments',
);
try {
// When filtering without query, get more messages to ensure we find all matches
final take = (trimmedQuery.isEmpty && hasFilters) ? 1000 : 50;
final messages = await _getCachedMessages(
offset: 0,
take: 50,
searchQuery: trimmedQuery,
take: take,
searchQuery: trimmedQuery.isNotEmpty ? trimmedQuery : null,
withLinks: withLinks,
withAttachments: withAttachments,
); // Limit initial search results

View File

@@ -9,6 +9,7 @@ import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/chat/message_list_tile.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
import 'package:island/services/responsive.dart';
import 'dart:async';
// Class to represent the result when popping from search messages
@@ -20,6 +21,90 @@ class SearchMessagesResult {
// Search states for better UX
enum SearchState { idle, searching, results, noResults, error }
class _SearchFilters extends StatelessWidget {
final ValueNotifier<bool> withLinks;
final ValueNotifier<bool> withAttachments;
final void Function(String) performSearch;
final TextEditingController searchController;
final bool isLarge;
const _SearchFilters({
required this.withLinks,
required this.withAttachments,
required this.performSearch,
required this.searchController,
required this.isLarge,
});
@override
Widget build(BuildContext context) {
if (isLarge) {
return Row(
children: [
IconButton(
icon: Icon(
Symbols.link,
color:
withLinks.value
? Theme.of(context).colorScheme.primary
: Theme.of(context).iconTheme.color,
),
onPressed: () {
withLinks.value = !withLinks.value;
performSearch(searchController.text);
},
tooltip: 'searchLinks'.tr(),
),
IconButton(
icon: Icon(
Symbols.file_copy,
color:
withAttachments.value
? Theme.of(context).colorScheme.primary
: Theme.of(context).iconTheme.color,
),
onPressed: () {
withAttachments.value = !withAttachments.value;
performSearch(searchController.text);
},
tooltip: 'searchAttachments'.tr(),
),
],
);
} else {
return Row(
children: [
FilterChip(
avatar: const Icon(Symbols.link, size: 16),
label: const Text('searchLinks').tr(),
selected: withLinks.value,
onSelected: (bool? value) {
withLinks.value = value!;
performSearch(searchController.text);
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
const SizedBox(width: 8),
FilterChip(
avatar: const Icon(Symbols.file_copy, size: 16),
label: const Text('searchAttachments').tr(),
selected: withAttachments.value,
onSelected: (bool? value) {
withAttachments.value = value!;
performSearch(searchController.text);
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
],
);
}
}
}
class SearchMessagesScreen extends HookConsumerWidget {
final String roomId;
@@ -45,7 +130,10 @@ class SearchMessagesScreen extends HookConsumerWidget {
// Optimized search function with debouncing
void performSearch(String query) async {
if (query.trim().isEmpty) {
final trimmedQuery = query.trim();
final hasFilters = withLinks.value || withAttachments.value;
if (trimmedQuery.isEmpty && !hasFilters) {
searchState.value = SearchState.idle;
searchResultCount.value = null;
searchResults.value = const AsyncValue.data([]);
@@ -97,6 +185,8 @@ class SearchMessagesScreen extends HookConsumerWidget {
return null;
}, []);
final isLarge = isWideScreen(context);
return AppScaffold(
appBar: AppBar(
title: const Text('searchMessages').tr(),
@@ -112,108 +202,179 @@ class SearchMessagesScreen extends HookConsumerWidget {
children: [
// Search input section
Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(8),
),
),
child: Column(
children: [
TextField(
controller: searchController,
autofocus: true,
decoration: InputDecoration(
hintText: 'searchMessagesHint'.tr(),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.only(
left: 16,
right: 16,
top: 12,
bottom: 16,
),
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (searchController.text.isNotEmpty)
IconButton(
iconSize: 18,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
performSearch('');
},
),
if (searchResultCount.value != null &&
searchState.value == SearchState.results)
Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${searchResultCount.value}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Material(
elevation: 2,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(32),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child:
isLarge
? Row(
children: [
Expanded(
child: TextField(
controller: searchController,
autofocus: true,
decoration: InputDecoration(
hintText: 'searchMessagesHint'.tr(),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (searchResultCount.value != null &&
searchState.value ==
SearchState.results)
Container(
margin: const EdgeInsets.only(
right: 8,
),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.1),
borderRadius: BorderRadius.circular(
12,
),
),
child: Text(
'${searchResultCount.value}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color:
Theme.of(
context,
).colorScheme.primary,
),
),
),
if (searchController.text.isNotEmpty)
IconButton(
iconSize: 18,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
performSearch('');
},
),
],
),
),
onChanged: performSearch,
),
),
),
],
),
),
onChanged: performSearch,
),
// Search filters
Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 8,
),
child: Row(
children: [
Expanded(
child: FilterChip(
avatar: const Icon(Symbols.link, size: 16),
label: const Text('searchLinks').tr(),
selected: withLinks.value,
onSelected: (bool? value) {
withLinks.value = value!;
performSearch(searchController.text);
},
const SizedBox(width: 16),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: _SearchFilters(
withLinks: withLinks,
withAttachments: withAttachments,
performSearch: performSearch,
searchController: searchController,
isLarge: isLarge,
),
),
],
)
: Column(
children: [
TextField(
controller: searchController,
autofocus: true,
decoration: InputDecoration(
hintText: 'searchMessagesHint'.tr(),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (searchResultCount.value != null &&
searchState.value ==
SearchState.results)
Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.1),
borderRadius: BorderRadius.circular(
12,
),
),
child: Text(
'${searchResultCount.value}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color:
Theme.of(
context,
).colorScheme.primary,
),
),
),
if (searchController.text.isNotEmpty)
IconButton(
iconSize: 18,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
performSearch('');
},
),
],
),
),
onChanged: performSearch,
),
Padding(
padding: const EdgeInsets.only(
left: 8,
right: 8,
top: 8,
bottom: 8,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_SearchFilters(
withLinks: withLinks,
withAttachments: withAttachments,
performSearch: performSearch,
searchController: searchController,
isLarge: false,
),
],
),
),
],
),
),
const SizedBox(width: 8),
Expanded(
child: FilterChip(
avatar: const Icon(Symbols.file_copy, size: 16),
label: const Text('searchAttachments').tr(),
selected: withAttachments.value,
onSelected: (bool? value) {
withAttachments.value = value!;
performSearch(searchController.text);
},
),
),
],
),
),
],
),
),
),
const Divider(height: 1),
// Search results section
Expanded(