509 lines
		
	
	
		
			21 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			509 lines
		
	
	
		
			21 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
import 'package:easy_localization/easy_localization.dart';
 | 
						|
import 'package:flutter/material.dart';
 | 
						|
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
						|
import 'package:go_router/go_router.dart';
 | 
						|
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
						|
import 'package:island/pods/chat/messages_notifier.dart';
 | 
						|
import 'package:island/pods/chat/chat_rooms.dart';
 | 
						|
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
 | 
						|
class SearchMessagesResult {
 | 
						|
  final String messageId;
 | 
						|
  const SearchMessagesResult(this.messageId);
 | 
						|
}
 | 
						|
 | 
						|
// 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;
 | 
						|
 | 
						|
  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 searchState = useState(SearchState.idle);
 | 
						|
    final searchResultCount = useState<int?>(null);
 | 
						|
    final searchResults = useState<AsyncValue<List<dynamic>>>(
 | 
						|
      const AsyncValue.data([]),
 | 
						|
    );
 | 
						|
 | 
						|
    // Debounce timer for search optimization
 | 
						|
    final debounceTimer = useRef<Timer?>(null);
 | 
						|
 | 
						|
    final messagesNotifier = ref.read(
 | 
						|
      messagesNotifierProvider(roomId).notifier,
 | 
						|
    );
 | 
						|
 | 
						|
    // Optimized search function with debouncing
 | 
						|
    void performSearch(String query) async {
 | 
						|
      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([]);
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      searchState.value = SearchState.searching;
 | 
						|
      searchResults.value = const AsyncValue.loading();
 | 
						|
 | 
						|
      // Cancel previous search if still active
 | 
						|
      debounceTimer.value?.cancel();
 | 
						|
 | 
						|
      // Debounce search to avoid excessive API calls
 | 
						|
      debounceTimer.value = Timer(const Duration(milliseconds: 300), () async {
 | 
						|
        try {
 | 
						|
          final results = await messagesNotifier.getSearchResults(
 | 
						|
            query.trim(),
 | 
						|
            withLinks: withLinks.value,
 | 
						|
            withAttachments: withAttachments.value,
 | 
						|
          );
 | 
						|
          searchResults.value = AsyncValue.data(results);
 | 
						|
          searchState.value =
 | 
						|
              results.isEmpty ? SearchState.noResults : SearchState.results;
 | 
						|
          searchResultCount.value = results.length;
 | 
						|
        } catch (error, stackTrace) {
 | 
						|
          searchResults.value = AsyncValue.error(error, stackTrace);
 | 
						|
          searchState.value = SearchState.error;
 | 
						|
        }
 | 
						|
      });
 | 
						|
    }
 | 
						|
 | 
						|
    // Search state is now managed locally in performSearch
 | 
						|
 | 
						|
    useEffect(() {
 | 
						|
      // Clear search when screen is disposed
 | 
						|
      return () {
 | 
						|
        debounceTimer.value?.cancel();
 | 
						|
        // Note: Don't access ref here as widget may be disposed
 | 
						|
        // Flashing messages will be cleared by the next screen or jump operation
 | 
						|
      };
 | 
						|
    }, []);
 | 
						|
 | 
						|
    // Clear flashing messages when screen initializes (safer than in dispose)
 | 
						|
    useEffect(() {
 | 
						|
      WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
						|
        // Clear flashing messages when entering search screen
 | 
						|
        ref.read(flashingMessagesProvider.notifier).state = {};
 | 
						|
      });
 | 
						|
      return null;
 | 
						|
    }, []);
 | 
						|
 | 
						|
    final isLarge = isWideScreen(context);
 | 
						|
 | 
						|
    return AppScaffold(
 | 
						|
      appBar: AppBar(
 | 
						|
        title: const Text('searchMessages').tr(),
 | 
						|
        bottom:
 | 
						|
            searchState.value == SearchState.searching
 | 
						|
                ? const PreferredSize(
 | 
						|
                  preferredSize: Size.fromHeight(2),
 | 
						|
                  child: LinearProgressIndicator(),
 | 
						|
                )
 | 
						|
                : null,
 | 
						|
      ),
 | 
						|
      body: Column(
 | 
						|
        children: [
 | 
						|
          // Search input section
 | 
						|
          Container(
 | 
						|
            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,
 | 
						|
                              ),
 | 
						|
                            ),
 | 
						|
                            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,
 | 
						|
                                  ),
 | 
						|
                                ],
 | 
						|
                              ),
 | 
						|
                            ),
 | 
						|
                          ],
 | 
						|
                        ),
 | 
						|
              ),
 | 
						|
            ),
 | 
						|
          ),
 | 
						|
 | 
						|
          // Search results section
 | 
						|
          Expanded(
 | 
						|
            child: searchResults.value.when(
 | 
						|
              data: (messageList) {
 | 
						|
                switch (searchState.value) {
 | 
						|
                  case SearchState.idle:
 | 
						|
                    return Center(
 | 
						|
                      child: Column(
 | 
						|
                        mainAxisAlignment: MainAxisAlignment.center,
 | 
						|
                        children: [
 | 
						|
                          Icon(
 | 
						|
                            Icons.search,
 | 
						|
                            size: 64,
 | 
						|
                            color: Theme.of(context).disabledColor,
 | 
						|
                          ),
 | 
						|
                          const SizedBox(height: 16),
 | 
						|
                          Text(
 | 
						|
                            'searchMessagesHint'.tr(),
 | 
						|
                            style: Theme.of(
 | 
						|
                              context,
 | 
						|
                            ).textTheme.bodyLarge?.copyWith(
 | 
						|
                              color: Theme.of(context).disabledColor,
 | 
						|
                            ),
 | 
						|
                          ),
 | 
						|
                        ],
 | 
						|
                      ),
 | 
						|
                    );
 | 
						|
 | 
						|
                  case SearchState.noResults:
 | 
						|
                    return Center(
 | 
						|
                      child: Column(
 | 
						|
                        mainAxisAlignment: MainAxisAlignment.center,
 | 
						|
                        children: [
 | 
						|
                          Icon(
 | 
						|
                            Icons.search_off,
 | 
						|
                            size: 64,
 | 
						|
                            color: Theme.of(context).disabledColor,
 | 
						|
                          ),
 | 
						|
                          const SizedBox(height: 16),
 | 
						|
                          Text(
 | 
						|
                            'noMessagesFound'.tr(),
 | 
						|
                            style: Theme.of(
 | 
						|
                              context,
 | 
						|
                            ).textTheme.bodyLarge?.copyWith(
 | 
						|
                              color: Theme.of(context).disabledColor,
 | 
						|
                            ),
 | 
						|
                          ),
 | 
						|
                          const SizedBox(height: 8),
 | 
						|
                          Text(
 | 
						|
                            'tryDifferentKeywords'.tr(),
 | 
						|
                            style: Theme.of(
 | 
						|
                              context,
 | 
						|
                            ).textTheme.bodySmall?.copyWith(
 | 
						|
                              color: Theme.of(context).disabledColor,
 | 
						|
                            ),
 | 
						|
                          ),
 | 
						|
                        ],
 | 
						|
                      ),
 | 
						|
                    );
 | 
						|
 | 
						|
                  case SearchState.results:
 | 
						|
                    return SuperListView.builder(
 | 
						|
                      padding: const EdgeInsets.symmetric(vertical: 16),
 | 
						|
                      reverse: false, // Show newest messages at the top
 | 
						|
                      itemCount: messageList.length,
 | 
						|
                      itemBuilder: (context, index) {
 | 
						|
                        final message = messageList[index];
 | 
						|
                        return MessageListTile(
 | 
						|
                          message: message,
 | 
						|
                          onJump: (messageId) {
 | 
						|
                            // Return the search result and pop back to room detail
 | 
						|
                            context.pop(SearchMessagesResult(messageId));
 | 
						|
                          },
 | 
						|
                        );
 | 
						|
                      },
 | 
						|
                    );
 | 
						|
 | 
						|
                  default:
 | 
						|
                    return const SizedBox.shrink();
 | 
						|
                }
 | 
						|
              },
 | 
						|
              loading: () {
 | 
						|
                if (searchState.value == SearchState.searching) {
 | 
						|
                  return const Center(
 | 
						|
                    child: Column(
 | 
						|
                      mainAxisAlignment: MainAxisAlignment.center,
 | 
						|
                      children: [
 | 
						|
                        CircularProgressIndicator(),
 | 
						|
                        SizedBox(height: 16),
 | 
						|
                        Text('Searching...'),
 | 
						|
                      ],
 | 
						|
                    ),
 | 
						|
                  );
 | 
						|
                }
 | 
						|
                return const Center(child: CircularProgressIndicator());
 | 
						|
              },
 | 
						|
              error: (error, _) {
 | 
						|
                return Center(
 | 
						|
                  child: Column(
 | 
						|
                    mainAxisAlignment: MainAxisAlignment.center,
 | 
						|
                    children: [
 | 
						|
                      Icon(
 | 
						|
                        Icons.error_outline,
 | 
						|
                        size: 64,
 | 
						|
                        color: Theme.of(context).colorScheme.error,
 | 
						|
                      ),
 | 
						|
                      const SizedBox(height: 16),
 | 
						|
                      Text(
 | 
						|
                        'searchError'.tr(),
 | 
						|
                        style: Theme.of(context).textTheme.bodyLarge?.copyWith(
 | 
						|
                          color: Theme.of(context).colorScheme.error,
 | 
						|
                        ),
 | 
						|
                      ),
 | 
						|
                      const SizedBox(height: 8),
 | 
						|
                      ElevatedButton.icon(
 | 
						|
                        onPressed: () => performSearch(searchController.text),
 | 
						|
                        icon: const Icon(Icons.refresh),
 | 
						|
                        label: const Text('retry').tr(),
 | 
						|
                      ),
 | 
						|
                    ],
 | 
						|
                  ),
 | 
						|
                );
 | 
						|
              },
 | 
						|
            ),
 | 
						|
          ),
 | 
						|
        ],
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |