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,108 +202,179 @@ 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),
children: [ child:
TextField( isLarge
controller: searchController, ? Row(
autofocus: true, children: [
decoration: InputDecoration( Expanded(
hintText: 'searchMessagesHint'.tr(), child: TextField(
border: InputBorder.none, controller: searchController,
isDense: true, autofocus: true,
contentPadding: const EdgeInsets.only( decoration: InputDecoration(
left: 16, hintText: 'searchMessagesHint'.tr(),
right: 16, border: InputBorder.none,
top: 12, isDense: true,
bottom: 16, contentPadding: const EdgeInsets.symmetric(
), horizontal: 12,
prefixIcon: const Icon(Icons.search, size: 20), vertical: 12,
suffixIcon: Row( ),
mainAxisSize: MainAxisSize.min, suffixIcon: Row(
children: [ mainAxisSize: MainAxisSize.min,
if (searchController.text.isNotEmpty) children: [
IconButton( if (searchResultCount.value != null &&
iconSize: 18, searchState.value ==
visualDensity: VisualDensity.compact, SearchState.results)
icon: const Icon(Icons.clear), Container(
onPressed: () { margin: const EdgeInsets.only(
searchController.clear(); right: 8,
performSearch(''); ),
}, padding: const EdgeInsets.symmetric(
), horizontal: 8,
if (searchResultCount.value != null && vertical: 2,
searchState.value == SearchState.results) ),
Container( decoration: BoxDecoration(
margin: const EdgeInsets.only(right: 8), color: Theme.of(context)
padding: const EdgeInsets.symmetric( .colorScheme
horizontal: 8, .primary
vertical: 2, .withOpacity(0.1),
), borderRadius: BorderRadius.circular(
decoration: BoxDecoration( 12,
color: Theme.of( ),
context, ),
).colorScheme.primary.withOpacity(0.1), child: Text(
borderRadius: BorderRadius.circular(12), '${searchResultCount.value}',
), style: TextStyle(
child: Text( fontSize: 12,
'${searchResultCount.value}', fontWeight: FontWeight.bold,
style: TextStyle( color:
fontSize: 12, Theme.of(
fontWeight: FontWeight.bold, context,
color: Theme.of(context).colorScheme.primary, ).colorScheme.primary,
),
),
),
if (searchController.text.isNotEmpty)
IconButton(
iconSize: 18,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
performSearch('');
},
),
],
),
),
onChanged: performSearch,
), ),
), ),
), const SizedBox(width: 16),
], SingleChildScrollView(
), scrollDirection: Axis.horizontal,
), child: _SearchFilters(
onChanged: performSearch, withLinks: withLinks,
), withAttachments: withAttachments,
// Search filters performSearch: performSearch,
Padding( searchController: searchController,
padding: const EdgeInsets.only( isLarge: isLarge,
left: 16, ),
right: 16, ),
bottom: 8, ],
), )
child: Row( : Column(
children: [ children: [
Expanded( TextField(
child: FilterChip( controller: searchController,
avatar: const Icon(Symbols.link, size: 16), autofocus: true,
label: const Text('searchLinks').tr(), decoration: InputDecoration(
selected: withLinks.value, hintText: 'searchMessagesHint'.tr(),
onSelected: (bool? value) { border: InputBorder.none,
withLinks.value = value!; isDense: true,
performSearch(searchController.text); 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 // Search results section
Expanded( Expanded(