diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d937323f..4c783863 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -40,6 +40,8 @@ PODS: - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter + - file_saver (0.0.1): + - Flutter - Firebase/CoreOnly (12.0.0): - FirebaseCore (~> 12.0.0) - Firebase/Crashlytics (12.0.0): @@ -303,6 +305,7 @@ DEPENDENCIES: - croppy (from `.symlinks/plugins/croppy/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) + - file_saver (from `.symlinks/plugins/file_saver/ios`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`) @@ -381,6 +384,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/device_info_plus/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" + file_saver: + :path: ".symlinks/plugins/file_saver/ios" firebase_analytics: :path: ".symlinks/plugins/firebase_analytics/ios" firebase_core: @@ -464,6 +469,7 @@ SPEC CHECKSUMS: DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be + file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 Firebase: 800d487043c0557d9faed71477a38d9aafb08a41 firebase_analytics: cd56fc56f75c1df30a6ff5290cd56e230996a76d firebase_core: 633e1851ffe1b9ab875f6467a4f574c79cef02e4 diff --git a/lib/database/drift_db.dart b/lib/database/drift_db.dart index bcb22066..311d8994 100644 --- a/lib/database/drift_db.dart +++ b/lib/database/drift_db.dart @@ -68,6 +68,30 @@ class AppDatabase extends _$AppDatabase { return (delete(chatMessages)..where((m) => m.id.equals(id))).go(); } + Future> searchMessages( + String roomId, + String query, + ) async { + var selectStatement = select(chatMessages) + ..where((m) => m.roomId.equals(roomId)); + + if (query.isNotEmpty) { + selectStatement = + selectStatement + ..where((m) => m.content.like('%${query.toLowerCase()}%')); + } + + + + + + final messages = + await (selectStatement + ..orderBy([(m) => OrderingTerm.desc(m.createdAt)])) + .get(); + return messages.map((msg) => companionToMessage(msg)).toList(); + } + // Convert between Drift and model objects ChatMessagesCompanion messageToCompanion(LocalChatMessage message) { return ChatMessagesCompanion( diff --git a/lib/main.dart b/lib/main.dart index 28ad1338..3e98cd4d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,7 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; + import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; diff --git a/lib/route.dart b/lib/route.dart index e1ffb862..6bd04a63 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -38,6 +38,7 @@ import 'package:island/screens/chat/chat.dart'; import 'package:island/screens/chat/room.dart'; import 'package:island/screens/chat/room_detail.dart'; import 'package:island/screens/chat/call.dart'; +import 'package:island/screens/chat/search_messages_screen.dart'; import 'package:island/screens/creators/hub.dart'; import 'package:island/screens/creators/posts/post_manage_list.dart'; import 'package:island/screens/creators/stickers/stickers.dart'; @@ -555,6 +556,14 @@ final routerProvider = Provider((ref) { return ChatDetailScreen(id: id); }, ), + GoRoute( + name: 'searchMessages', + path: '/chat/:id/search', + builder: (context, state) { + final id = state.pathParameters['id']!; + return SearchMessagesScreen(roomId: id); + }, + ), ], ), diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index a910bff0..24ec1554 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -283,6 +283,9 @@ class MessagesNotifier extends _$MessagesNotifier { final Map _pendingMessages = {}; final Map> _fileUploadProgress = {}; int? _totalCount; + String? _searchQuery; + bool? _withLinks; + bool? _withAttachments; late final String _roomId; int _currentPage = 0; @@ -326,7 +329,13 @@ class MessagesNotifier extends _$MessagesNotifier { }); } - return await loadInitial(); + loadInitial(); + return []; + } + + List _sortMessages(List messages) { + messages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return messages; } Future> _getCachedMessages({ @@ -337,13 +346,29 @@ class MessagesNotifier extends _$MessagesNotifier { 'Getting cached messages from offset $offset, take $take', name: 'MessagesNotifier', ); - final dbMessages = await _database.getMessagesForRoom( - _roomId, - offset: offset, - limit: take, - ); - final dbLocalMessages = - dbMessages.map(_database.companionToMessage).toList(); + final List dbMessages; + if (_searchQuery != null && _searchQuery!.isNotEmpty) { + dbMessages = await _database.searchMessages(_roomId, _searchQuery ?? ''); + } else { + final chatMessagesFromDb = await _database.getMessagesForRoom( + _roomId, + offset: offset, + limit: take, + ); + dbMessages = chatMessagesFromDb.map(_database.companionToMessage).toList(); + } + + List filteredMessages = dbMessages; + + if (_withLinks == true) { + filteredMessages = filteredMessages.where((msg) => _hasLink(msg)).toList(); + } + + if (_withAttachments == true) { + filteredMessages = filteredMessages.where((msg) => _hasAttachment(msg)).toList(); + } + + final dbLocalMessages = filteredMessages; if (offset == 0) { final pendingForRoom = @@ -352,7 +377,7 @@ class MessagesNotifier extends _$MessagesNotifier { .toList(); final allMessages = [...pendingForRoom, ...dbLocalMessages]; - allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + _sortMessages(allMessages); // Use the helper function final uniqueMessages = []; final seenIds = {}; @@ -427,7 +452,7 @@ class MessagesNotifier extends _$MessagesNotifier { _isSyncing = true; developer.log('Starting message sync', name: 'MessagesNotifier'); - ref.read(isSyncingProvider.notifier).state = true; + Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true); try { final dbMessages = await _database.getMessagesForRoom( _room.id, @@ -488,7 +513,7 @@ class MessagesNotifier extends _$MessagesNotifier { showErrorAlert(err); } finally { developer.log('Finished message sync', name: 'MessagesNotifier'); - ref.read(isSyncingProvider.notifier).state = false; + Future.microtask(() => ref.read(isSyncingProvider.notifier).state = false); _isSyncing = false; } } @@ -499,7 +524,7 @@ class MessagesNotifier extends _$MessagesNotifier { bool synced = false, }) async { try { - if (offset == 0 && !synced) { + if (offset == 0 && !synced && (_searchQuery == null || _searchQuery!.isEmpty)) { _fetchAndCacheMessages(offset: 0, take: take).catchError((_) { return []; }); @@ -514,7 +539,11 @@ class MessagesNotifier extends _$MessagesNotifier { return localMessages; } - return await _fetchAndCacheMessages(offset: offset, take: take); + if (_searchQuery == null || _searchQuery!.isEmpty) { + return await _fetchAndCacheMessages(offset: offset, take: take); + } else { + return []; // If searching, and no local messages, don't fetch from network + } } catch (e) { final localMessages = await _getCachedMessages( offset: offset, @@ -528,13 +557,15 @@ class MessagesNotifier extends _$MessagesNotifier { } } - Future> loadInitial() async { + Future loadInitial() async { developer.log('Loading initial messages', name: 'MessagesNotifier'); - syncMessages(); + if (_searchQuery == null || _searchQuery!.isEmpty) { + syncMessages(); + } final messages = await _getCachedMessages(offset: 0, take: 100); _currentPage = 0; _hasMore = messages.length == _pageSize; - return messages; + state = AsyncValue.data(messages); } Future loadMore() async { @@ -553,7 +584,7 @@ class MessagesNotifier extends _$MessagesNotifier { _hasMore = false; } - state = AsyncValue.data([...currentMessages, ...newMessages]); + state = AsyncValue.data(_sortMessages([...currentMessages, ...newMessages])); } catch (err, stackTrace) { developer.log( 'Error loading more messages', @@ -778,7 +809,7 @@ class MessagesNotifier extends _$MessagesNotifier { } return m; }).toList(); - state = AsyncValue.data(newMessages); + state = AsyncValue.data(_sortMessages(newMessages)); showErrorAlert(e); } } @@ -838,7 +869,7 @@ class MessagesNotifier extends _$MessagesNotifier { if (index >= 0) { final newList = [...currentMessages]; newList[index] = updatedMessage; - state = AsyncValue.data(newList); + state = AsyncValue.data(_sortMessages(newList)); } } @@ -898,6 +929,20 @@ class MessagesNotifier extends _$MessagesNotifier { } } + void searchMessages(String query, {bool? withLinks, bool? withAttachments}) { + _searchQuery = query.trim(); + _withLinks = withLinks; + _withAttachments = withAttachments; + loadInitial(); + } + + void clearSearch() { + _searchQuery = null; + _withLinks = null; + _withAttachments = null; + loadInitial(); + } + Future fetchMessageById(String messageId) async { developer.log( 'Fetching message by id $messageId', @@ -927,6 +972,18 @@ class MessagesNotifier extends _$MessagesNotifier { rethrow; } } + + bool _hasLink(LocalChatMessage message) { + final content = message.toRemoteMessage().content; + if (content == null) return false; + final urlRegex = RegExp(r'https?://[^\s/$.?#].[^\s]*'); + return urlRegex.hasMatch(content); + } + + bool _hasAttachment(LocalChatMessage message) { + final remoteMessage = message.toRemoteMessage(); + return remoteMessage.attachments.isNotEmpty; + } } class ChatRoomScreen extends HookConsumerWidget { @@ -1424,6 +1481,12 @@ class ChatRoomScreen extends HookConsumerWidget { ), ), actions: [ + IconButton( + icon: const Icon(Icons.search), + onPressed: () { + context.pushNamed('searchMessages', pathParameters: {'id': id}); + }, + ), AudioCallButton(roomId: id), IconButton( icon: const Icon(Icons.more_vert), @@ -1433,15 +1496,14 @@ class ChatRoomScreen extends HookConsumerWidget { ), const Gap(8), ], - bottom: - isSyncing - ? const PreferredSize( - preferredSize: Size.fromHeight(2), - child: LinearProgressIndicator( - borderRadius: BorderRadius.zero, - ), - ) - : null, + bottom: isSyncing + ? const PreferredSize( + preferredSize: Size.fromHeight(2), + child: LinearProgressIndicator( + borderRadius: BorderRadius.zero, + ), + ) + : null, ), body: Stack( children: [ diff --git a/lib/screens/chat/room_detail.dart b/lib/screens/chat/room_detail.dart index 5b59010e..3981577b 100644 --- a/lib/screens/chat/room_detail.dart +++ b/lib/screens/chat/room_detail.dart @@ -359,6 +359,17 @@ class ChatDetailScreen extends HookConsumerWidget { : const Text('chatBreakNone').tr(), onTap: () => showChatBreakDialog(), ), + ListTile( + contentPadding: EdgeInsets.symmetric( + horizontal: 24, + ), + leading: const Icon(Icons.search), + trailing: const Icon(Symbols.chevron_right), + title: const Text('Search Messages').tr(), + onTap: () { + context.pushNamed('searchMessages', pathParameters: {'id': id}); + }, + ), ], ), error: (_, _) => const SizedBox.shrink(), diff --git a/lib/screens/chat/search_messages_screen.dart b/lib/screens/chat/search_messages_screen.dart new file mode 100644 index 00000000..1d3d440c --- /dev/null +++ b/lib/screens/chat/search_messages_screen.dart @@ -0,0 +1,139 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/screens/chat/room.dart'; +import 'package:island/widgets/app_scaffold.dart'; +import 'package:island/widgets/chat/message_item.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:super_sliver_list/super_sliver_list.dart'; + +class SearchMessagesScreen extends HookConsumerWidget { + final String roomId; + + const SearchMessagesScreen({super.key, required this.roomId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final searchController = useTextEditingController(); + final withLinks = useState(false); + final withAttachments = useState(false); + + final messagesNotifier = ref.read( + messagesNotifierProvider(roomId).notifier, + ); + final messages = ref.watch(messagesNotifierProvider(roomId)); + + useEffect(() { + // Clear search when screen is disposed + return () { + messagesNotifier.clearSearch(); + }; + }, []); + + return AppScaffold( + appBar: AppBar(title: const Text('Search Messages')), + body: Column( + children: [ + Column( + children: [ + TextField( + controller: searchController, + decoration: InputDecoration( + hintText: 'Search messages...', + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.only( + left: 16, + right: 16, + top: 12, + bottom: 16, + ), + suffix: IconButton( + iconSize: 18, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.clear), + onPressed: () { + searchController.clear(); + messagesNotifier.clearSearch(); + }, + ), + ), + onChanged: (query) { + messagesNotifier.searchMessages( + query, + withLinks: withLinks.value, + withAttachments: withAttachments.value, + ); + }, + ), + Row( + children: [ + Expanded( + child: CheckboxListTile( + secondary: const Icon(Symbols.link), + title: const Text('Links'), + value: withLinks.value, + onChanged: (bool? value) { + withLinks.value = value!; + messagesNotifier.searchMessages( + searchController.text, + withLinks: withLinks.value, + withAttachments: withAttachments.value, + ); + }, + ), + ), + Expanded( + child: CheckboxListTile( + secondary: const Icon(Symbols.file_copy), + title: const Text('Attachments'), + value: withAttachments.value, + onChanged: (bool? value) { + withAttachments.value = value!; + messagesNotifier.searchMessages( + searchController.text, + withLinks: withLinks.value, + withAttachments: withAttachments.value, + ); + }, + ), + ), + ], + ), + ], + ), + const Divider(height: 1), + Expanded( + child: messages.when( + data: + (messageList) => + messageList.isEmpty + ? Center(child: Text('No messages found'.tr())) + : SuperListView.builder( + padding: const EdgeInsets.symmetric(vertical: 16), + reverse: true, // Show newest messages at the bottom + itemCount: messageList.length, + itemBuilder: (context, index) { + final message = messageList[index]; + // Simplified MessageItem for search results, no grouping logic + return MessageItem( + message: message, + isCurrentUser: + false, // Or determine based on actual user + onAction: null, + onJump: (_) {}, + progress: null, + showAvatar: true, + ); + }, + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('Error: $error')), + ), + ), + ], + ), + ); + } +}