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 { }) async {
talker.log('Getting cached messages from offset $offset, take $take'); talker.log('Getting cached messages from offset $offset, take $take');
final List<LocalChatMessage> dbMessages; final List<LocalChatMessage> dbMessages;
if (searchQuery != null && searchQuery.isNotEmpty) { if (searchQuery != null && searchQuery.isNotEmpty) {
dbMessages = await _database.searchMessages( dbMessages = await _database.searchMessages(
_roomId, _roomId,
@@ -147,6 +148,13 @@ class MessagesNotifier extends _$MessagesNotifier {
filteredMessages.where((msg) => _hasLink(msg)).toList(); filteredMessages.where((msg) => _hasLink(msg)).toList();
} }
if (withAttachments == true) {
filteredMessages =
filteredMessages
.where((msg) => msg.toRemoteMessage().attachments.isNotEmpty)
.toList();
}
final dbLocalMessages = filteredMessages; final dbLocalMessages = filteredMessages;
// Always ensure unique messages to prevent duplicate keys // Always ensure unique messages to prevent duplicate keys
@@ -778,18 +786,23 @@ class MessagesNotifier extends _$MessagesNotifier {
bool? withAttachments, bool? withAttachments,
}) async { }) async {
final trimmedQuery = query.trim(); final trimmedQuery = query.trim();
final hasFilters = [withLinks, withAttachments].any((e) => e == true);
if (trimmedQuery.isEmpty) { if (trimmedQuery.isEmpty && !hasFilters) {
return []; return [];
} }
talker.log('Getting search results for query: $trimmedQuery'); talker.log(
'Getting search results for query: $trimmedQuery, filters: links=$withLinks, attachments=$withAttachments',
);
try { 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( final messages = await _getCachedMessages(
offset: 0, offset: 0,
take: 50, take: take,
searchQuery: trimmedQuery, searchQuery: trimmedQuery.isNotEmpty ? trimmedQuery : null,
withLinks: withLinks, withLinks: withLinks,
withAttachments: withAttachments, withAttachments: withAttachments,
); // Limit initial search results ); // 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:island/widgets/chat/message_list_tile.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:super_sliver_list/super_sliver_list.dart'; import 'package:super_sliver_list/super_sliver_list.dart';
import 'package:island/services/responsive.dart';
import 'dart:async'; import 'dart:async';
// Class to represent the result when popping from search messages // Class to represent the result when popping from search messages
@@ -20,6 +21,90 @@ class SearchMessagesResult {
// Search states for better UX // Search states for better UX
enum SearchState { idle, searching, results, noResults, error } 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 { class SearchMessagesScreen extends HookConsumerWidget {
final String roomId; final String roomId;
@@ -45,7 +130,10 @@ class SearchMessagesScreen extends HookConsumerWidget {
// Optimized search function with debouncing // Optimized search function with debouncing
void performSearch(String query) async { 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; searchState.value = SearchState.idle;
searchResultCount.value = null; searchResultCount.value = null;
searchResults.value = const AsyncValue.data([]); searchResults.value = const AsyncValue.data([]);
@@ -97,6 +185,8 @@ class SearchMessagesScreen extends HookConsumerWidget {
return null; return null;
}, []); }, []);
final isLarge = isWideScreen(context);
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('searchMessages').tr(), title: const Text('searchMessages').tr(),
@@ -112,31 +202,64 @@ class SearchMessagesScreen extends HookConsumerWidget {
children: [ children: [
// Search input section // Search input section
Container( Container(
decoration: BoxDecoration( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: Theme.of(context).cardColor, child: Material(
borderRadius: const BorderRadius.vertical( elevation: 2,
bottom: Radius.circular(8), color: Theme.of(context).colorScheme.surfaceContainerHighest,
), borderRadius: BorderRadius.circular(32),
), child: Padding(
child: Column( padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child:
isLarge
? Row(
children: [ children: [
TextField( Expanded(
child: TextField(
controller: searchController, controller: searchController,
autofocus: true, autofocus: true,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'searchMessagesHint'.tr(), hintText: 'searchMessagesHint'.tr(),
border: InputBorder.none, border: InputBorder.none,
isDense: true, isDense: true,
contentPadding: const EdgeInsets.only( contentPadding: const EdgeInsets.symmetric(
left: 16, horizontal: 12,
right: 16, vertical: 12,
top: 12,
bottom: 16,
), ),
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: Row( suffixIcon: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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) if (searchController.text.isNotEmpty)
IconButton( IconButton(
iconSize: 18, iconSize: 18,
@@ -147,8 +270,44 @@ class SearchMessagesScreen extends HookConsumerWidget {
performSearch(''); performSearch('');
}, },
), ),
],
),
),
onChanged: performSearch,
),
),
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 && if (searchResultCount.value != null &&
searchState.value == SearchState.results) searchState.value ==
SearchState.results)
Container( Container(
margin: const EdgeInsets.only(right: 8), margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -156,56 +315,57 @@ class SearchMessagesScreen extends HookConsumerWidget {
vertical: 2, vertical: 2,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of( color: Theme.of(context)
context, .colorScheme
).colorScheme.primary.withOpacity(0.1), .primary
borderRadius: BorderRadius.circular(12), .withOpacity(0.1),
borderRadius: BorderRadius.circular(
12,
),
), ),
child: Text( child: Text(
'${searchResultCount.value}', '${searchResultCount.value}',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary, 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(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 16, left: 8,
right: 16, right: 8,
top: 8,
bottom: 8, bottom: 8,
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Expanded( _SearchFilters(
child: FilterChip( withLinks: withLinks,
avatar: const Icon(Symbols.link, size: 16), withAttachments: withAttachments,
label: const Text('searchLinks').tr(), performSearch: performSearch,
selected: withLinks.value, searchController: searchController,
onSelected: (bool? value) { isLarge: false,
withLinks.value = value!;
performSearch(searchController.text);
},
),
),
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);
},
),
), ),
], ],
), ),
@@ -213,7 +373,8 @@ class SearchMessagesScreen extends HookConsumerWidget {
], ],
), ),
), ),
const Divider(height: 1), ),
),
// Search results section // Search results section
Expanded( Expanded(