import 'dart:async'; import 'dart:io'; import 'dart:collection'; import 'dart:math' as math; import 'package:country_codes/country_codes.dart'; import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:path_provider/path_provider.dart'; import 'package:search_engine/screens/content.dart'; import 'package:search_engine/widgets/base.dart'; import 'package:skeletonizer/skeletonizer.dart'; import '../database.dart'; import 'package:drift/drift.dart' as drift; import 'package:search_engine/utils.dart' as utils; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart' as nav; import 'package:flutter/services.dart'; import 'package:search_engine/services/mimir_service.dart'; // Clase auxiliar para representar una coincidencia class _Match { final int start; final int end; final String text; _Match({required this.start, required this.end, required this.text}); } // Clase auxiliar para representar un fragmento de texto class _TextFragment { final String beforeMatch; final String matchText; final String afterMatch; _TextFragment({ required this.beforeMatch, required this.matchText, required this.afterMatch, }); } // Clase para el caché de búsqueda class SearchCache { // LRU Cache con capacidad máxima final int _maxSize; final LinkedHashMap> _cache = LinkedHashMap(); final Map _totalResultsCache = {}; // Almacena todos los resultados de una búsqueda final Map> _allResultsCache = {}; // Almacena los resultados de Mimir (IDs y snippets) final Map>> _mimirResultsCache = {}; // Almacena todos los resultados de Mimir para una consulta final Map>> _allMimirResultsCache = {}; SearchCache({int maxSize = 10}) : _maxSize = maxSize; // Obtener resultados del caché List? get(String query) { final normalizedQuery = query.trim().toLowerCase(); if (!_cache.containsKey(normalizedQuery)) return null; // Mover el elemento al final (más reciente) final results = _cache.remove(normalizedQuery); _cache[normalizedQuery] = results!; return results; } // Guardar resultados en el caché void put(String query, List results) { final normalizedQuery = query.trim().toLowerCase(); // Si el caché está lleno, eliminar el elemento más antiguo if (_cache.length >= _maxSize) { final oldestKey = _cache.keys.first; _cache.remove(oldestKey); // También eliminar del caché de todos los resultados _allResultsCache.remove(oldestKey); _totalResultsCache.remove(oldestKey); _mimirResultsCache.remove(oldestKey); _allMimirResultsCache.remove(oldestKey); } _cache[normalizedQuery] = results; } // Limpiar el caché void clear() { _cache.clear(); _totalResultsCache.clear(); _allResultsCache.clear(); _mimirResultsCache.clear(); _allMimirResultsCache.clear(); } // Obtener el total de resultados para una consulta int? getTotalResults(String query) { final normalizedQuery = query.trim().toLowerCase(); return _totalResultsCache[normalizedQuery]; } // Guardar el total de resultados para una consulta void setTotalResults(String query, int total) { final normalizedQuery = query.trim().toLowerCase(); _totalResultsCache[normalizedQuery] = total; } // Guardar todos los resultados de una búsqueda void putAllResults(String query, List results) { final normalizedQuery = query.trim().toLowerCase(); _allResultsCache[normalizedQuery] = results; } // Obtener todos los resultados de una búsqueda List? getAllResults(String query) { final normalizedQuery = query.trim().toLowerCase(); return _allResultsCache[normalizedQuery]; } // Guardar los resultados de Mimir (IDs y snippets) void setMimirIds(String query, List> mimirResults) { final normalizedQuery = query.trim().toLowerCase(); _mimirResultsCache[normalizedQuery] = mimirResults; // También actualizar el total de resultados _totalResultsCache[normalizedQuery] = mimirResults.length; } // Obtener los resultados de Mimir (IDs y snippets) List>? getMimirIds(String query) { final normalizedQuery = query.trim().toLowerCase(); return _mimirResultsCache[normalizedQuery]; } // Guardar todos los resultados de Mimir para una consulta void setAllMimirResults(String query, List> results) { final normalizedQuery = query.trim().toLowerCase(); _allMimirResultsCache[normalizedQuery] = results; } // Obtener todos los resultados de Mimir para una consulta List>? getAllMimirResults(String query) { final normalizedQuery = query.trim().toLowerCase(); return _allMimirResultsCache[normalizedQuery]; } } class SearchPage extends StatefulWidget { final String? searchTerm; const SearchPage({super.key, this.searchTerm}); @override // ignore: library_private_types_in_public_api _SearchPageState createState() => _SearchPageState(); } class _SearchPageState extends State with TickerProviderStateMixin { late TextEditingController _searchController; late ScrollController _scrollController; late AppDatabase database; late Directory appDirectory; int resultCount = 0; int totalResults = 0; bool isLoading = false; // Nuevas variables para separar las fases de carga bool mimirLoading = false; // Para la fase de obtención de IDs de Mimir bool resultsLoading = false; // Para la fase de carga de detalles Timer? _debounce; final _baseUrl = dotenv.env['BASE_URL']; final _token = dotenv.env['TOKEN']; bool _isSearching = false; late AnimationController _animationController; late Animation _fadeAnimation; // Controlador para la animación de pulso del indicador de búsqueda late AnimationController _pulseController; late Animation _pulseAnimation; // Controlador de paginación final PagingController _pagingController = PagingController( firstPageKey: 1, ); // Tamaño de página para la carga incremental static const _pageSize = 10; // Consulta actual String _currentQuery = ''; // Almacena todos los resultados de la búsqueda actual List? _allSearchResults; // Instancia del caché de búsqueda final SearchCache _searchCache = SearchCache(maxSize: 20); final _mimirService = MimirService(); // Caché para nombres de países final Map _countryNameCache = {}; @override void initState() { super.initState(); _searchController = TextEditingController(text: widget.searchTerm); _scrollController = ScrollController(); _initAppDirectory(); database = AppDatabase(); _mimirService.initialize(); _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation( parent: _animationController, curve: Curves.easeIn, ), ); // Inicializar el controlador de la animación de pulso _pulseController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), )..repeat(reverse: true); _pulseAnimation = Tween(begin: 0.8, end: 1.2).animate( CurvedAnimation( parent: _pulseController, curve: Curves.easeInOut, ), ); // Configurar el listener para la paginación _pagingController.addPageRequestListener((pageKey) { if (_currentQuery.isNotEmpty) { _fetchPage(pageKey); } }); if (_searchController.text.isNotEmpty) { Timer(const Duration(milliseconds: 100), () { _onSearch(_searchController.text); }); } } @override void dispose() { _pagingController.dispose(); _animationController.dispose(); _pulseController.dispose(); _scrollController.dispose(); database.close(); _debounce?.cancel(); _searchController.dispose(); super.dispose(); } void _onSearch(String value) { if (_searchController.text.trim().isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { _isSearching = true; _currentQuery = _searchController.text; totalResults = 0; isLoading = true; mimirLoading = true; resultsLoading = true; _allSearchResults = null; }); // Limpiar caché de resaltado para la nueva búsqueda _highlightedSpansCache.clear(); // Refrescar el controlador de paginación _pagingController.refresh(); _animationController.forward(); } }); } else { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { _isSearching = false; _currentQuery = ''; totalResults = 0; isLoading = false; mimirLoading = false; resultsLoading = false; _allSearchResults = null; }); // Limpiar caché de resaltado _highlightedSpansCache.clear(); // Limpiar y refrescar el controlador de paginación _pagingController.itemList?.clear(); _pagingController.refresh(); if (_animationController.isAnimating) { _animationController.stop(); } if (_animationController.value > 0) { _animationController.reverse(); } } }); } } void _clearSearch() { _searchController.clear(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { _isSearching = false; _currentQuery = ''; isLoading = false; mimirLoading = false; resultsLoading = false; _allSearchResults = null; }); // Limpiar caché de resaltado _highlightedSpansCache.clear(); // Refrescar el controlador de paginación _pagingController.refresh(); if (_animationController.isAnimating) { _animationController.stop(); } if (_animationController.value > 0) { _animationController.reverse(); } } }); } // Nueva función para búsqueda completa en Mimir static Future> _searchMimirFullIsolate( List params) async { final query = params[0] as String; final locale = params[1] as String; final token = params[2] as RootIsolateToken; BackgroundIsolateBinaryMessenger.ensureInitialized(token); final mimirService = MimirService(); final response = await mimirService.search( query, locale, limit: 10000, offset: 0, ); return response; } Future _fetchPage(int pageKey) async { if (!mounted) return; try { // Check if results are in cache final cachedResults = _searchCache.get('${_currentQuery}_page_$pageKey'); if (cachedResults != null) { final isLastPage = cachedResults.length < _pageSize; if (isLastPage) { _pagingController.appendLastPage(cachedResults); } else { _pagingController.appendPage(cachedResults, pageKey + 1); } return; } // Calculate offset and limit for pagination final offset = (pageKey - 1) * _pageSize; final limit = _pageSize; // Show loading state immediately WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { isLoading = true; mimirLoading = pageKey == 1; // Only show Mimir loading for first page resultsLoading = true; }); } }); // Get the RootIsolateToken before starting the search final token = RootIsolateToken.instance; if (token == null) { throw Exception('RootIsolateToken is not initialized'); } // For the first page, perform the complete Mimir search List> mimirResults; int total = 0; if (pageKey == 1) { // For the first page, do a complete search in Mimir // Using Future to avoid blocking the UI final searchResponse = await _mimirService.search( _currentQuery, context.locale.toString(), limit: 500, // Increased limit to show more results offset: 0, ); // Obtener resultados únicos por ID de documento final allMimirResults = (searchResponse['results'] as List>) .fold>>({}, (map, result) { if (!map.containsKey(result['id'])) { map[result['id']!] = result; } return map; }) .values .toList(); // Use the total from the search response, not just the length of the filtered results total = searchResponse['total'] as int; // This will get the actual total from the search engine // Update total results immediately, after the frame WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { totalResults = total; mimirLoading = false; }); } }); // Store all Mimir results for future use _searchCache.setAllMimirResults(_currentQuery, allMimirResults); _searchCache.setTotalResults(_currentQuery, total); // Get only the results for the first page mimirResults = allMimirResults.sublist(0, limit.clamp(0, allMimirResults.length)); // If there are no results, finish here if (mimirResults.isEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { isLoading = false; resultsLoading = false; }); _pagingController.appendLastPage([]); } }); return; } // Use compute to process results in the background final List ids = mimirResults.map((result) => result['id']!).toList(); final String locale = context.locale.toString(); // Prepare parameters for computation final computeParams = { 'ids': ids, 'locale': locale, 'mimirResults': mimirResults, 'token': token, }; // Process database query in compute function compute, List>( _processDbResultsInBackground, computeParams) .then((pageResults) { // Save this page in cache _searchCache.put('${_currentQuery}_page_$pageKey', pageResults); // Update state after the frame WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { isLoading = false; resultsLoading = false; }); // Show complete results final isLastPage = pageResults.length < _pageSize; if (isLastPage) { _pagingController.appendLastPage(pageResults); } else { _pagingController.appendPage(pageResults, pageKey + 1); } } }); }).catchError((error) { if (kDebugMode) { print('Error processing DB results: $error'); } // In case of error, show basic results after the frame WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { isLoading = false; resultsLoading = false; }); _pagingController.error = error; } }); }); } else { // For subsequent pages, use stored results final allMimirResults = _searchCache.getAllMimirResults(_currentQuery); if (allMimirResults == null) { _pagingController.appendLastPage([]); return; } total = _searchCache.getTotalResults(_currentQuery) ?? 0; final start = offset.clamp(0, allMimirResults.length); final end = (offset + limit).clamp(0, allMimirResults.length); mimirResults = allMimirResults.sublist(start, end); // Similar processing for subsequent pages final List ids = mimirResults.map((result) => result['id']!).toList(); final String locale = context.locale.toString(); // Prepare parameters for computation final computeParams = { 'ids': ids, 'locale': locale, 'mimirResults': mimirResults, 'token': token, }; // Process database query in compute function compute, List>( _processDbResultsInBackground, computeParams) .then((pageResults) { // Save this page in cache _searchCache.put('${_currentQuery}_page_$pageKey', pageResults); // Show results final isLastPage = pageResults.length < _pageSize; if (isLastPage) { _pagingController.appendLastPage(pageResults); } else { _pagingController.appendPage(pageResults, pageKey + 1); } // Update state WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { isLoading = false; resultsLoading = false; }); } }); }).catchError((error) { if (kDebugMode) { print('Error loading DB details on page $pageKey: $error'); } _pagingController.error = error; }); } } catch (e) { if (kDebugMode) { print('Error in pagination: $e'); } WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { isLoading = false; mimirLoading = false; resultsLoading = false; }); _pagingController.error = e; } }); } } // Background processing function for database operations static Future> _processDbResultsInBackground( Map params) async { final List ids = params['ids']; final String locale = params['locale']; final List> mimirResults = params['mimirResults']; final RootIsolateToken token = params['token']; BackgroundIsolateBinaryMessenger.ensureInitialized(token); final database = AppDatabase(); try { // Single query to get all details final String searchQuery = ''' SELECT DISTINCT m.id AS id, t.title AS title, m.country AS country, m.city AS city, m.activity AS activity, m.draft AS draft, m.thumbnail AS thumbnail, t.languages_code AS locale, m.date AS date, t.languages_code AS languages_code, t.pdf AS pdf FROM messages AS m LEFT JOIN translations AS t ON m.id = t.message_id WHERE t.languages_code = ? AND m.id IN (${List.filled(ids.length, '?').join(',')}) ORDER BY m.date DESC '''; // Prepare parameters: first the locale, then the IDs final variables = [ drift.Variable.withString(locale), ...ids.map((id) => drift.Variable.withString(id)), ]; final dbResults = await database .customSelect( searchQuery, variables: variables, ) .get(); // Create a map to relate IDs with DB results final Map> resultsMap = {}; for (final row in dbResults) { final id = row.read('id'); resultsMap[id] = { 'id': id, 'title': row.read('title') ?? '', 'date': row.read('date'), 'activity': row.read('activity') ?? 0, 'thumbnail': row.read('thumbnail') ?? '', 'draft': row.read('draft') ?? 0, 'locale': row.read('locale') ?? '', 'country': row.read('country') ?? '', 'city': row.read('city') ?? '', 'pdf': row.read('pdf') ?? '', 'languagesCode': row.read('languages_code') ?? '', }; } // Combine Mimir information with DB results final List pageResults = []; for (final result in mimirResults) { final id = result['id']!; if (resultsMap.containsKey(id)) { final dbData = resultsMap[id]!; // Create a new Draft object with DB and Mimir data final draft = Draft( id: id, title: dbData['title'] as String, date: dbData['date'] as DateTime, activity: dbData['activity'] as int, thumbnail: dbData['thumbnail'] as String, draft: dbData['draft'] as int, locale: dbData['locale'] as String, body: result['content']!, position: int.tryParse(result['position'] ?? '0') ?? 0, length: int.tryParse(result['length'] ?? '0') ?? 0, country: dbData['country'] as String, city: dbData['city'] as String, pdf: dbData['pdf'] as String, languagesCode: dbData['languagesCode'] as String, ); pageResults.add(draft); } } return pageResults; } catch (e) { // If there's an error, create basic results with available information final List basicResults = mimirResults.map((result) { final content = result['content'] ?? ''; // Extract a title from content if possible String title = 'No title'; if (content.length > 30) { title = content.substring(0, 30).replaceAll('\n', ' ') + '...'; } else if (content.isNotEmpty) { title = content.replaceAll('\n', ' '); } return Draft( id: result['id']!, title: title, date: DateTime.now(), activity: 0, thumbnail: '', draft: 0, locale: locale, body: content, position: int.tryParse(result['position'] ?? '0') ?? 0, length: int.tryParse(result['length'] ?? '0') ?? 0, country: '', city: '', pdf: '', languagesCode: locale, ); }).toList(); return basicResults; } finally { // Close database connection when done database.close(); } } Future _initAppDirectory() async { appDirectory = await getApplicationDocumentsDirectory(); } Future _getThumbnail(String directoryPath, String fileId) async { final dir = Directory(directoryPath); if (!await dir.exists()) { await dir.create(recursive: true); } final entities = await dir.list().toList(); for (var entity in entities) { if (entity is File) { final fileName = entity.uri.pathSegments.last; if (fileName == '$fileId+SD.jpg') { return entity; } } } final String url = '$_baseUrl/assets/$fileId?access_token=$_token&width=320&height=180&quality=50&fit=cover&format=jpg'; final String thumbnailPath = '$directoryPath/$fileId+SD.jpg'; if (fileId == '') { return null; } try { await Dio().download(url, thumbnailPath); return File(thumbnailPath); } catch (e) { if (kDebugMode) { print('Error downloading thumbnail: $e'); } return null; } } Widget _buildThumbnail(String thumbnailId) { // Evitar cargar miniaturas para thumbnailId vacío if (thumbnailId.isEmpty) { return Image.asset( 'assets/image/default_thumbnail.jpg', height: 80, width: 100, fit: BoxFit.cover, alignment: Alignment.center, cacheHeight: 160, cacheWidth: 200, filterQuality: FilterQuality.low, gaplessPlayback: true, ); } return FutureBuilder( future: _getThumbnail( '${appDirectory.path}/LGCC_Search/${context.locale.toString()}/thumbnails/', thumbnailId), builder: (context, snapshot) { return Skeletonizer( key: ValueKey( 'skeleton_thumbnail_${thumbnailId}_${snapshot.connectionState}'), enableSwitchAnimation: true, enabled: snapshot.connectionState != ConnectionState.done, effect: const ShimmerEffect( baseColor: Color(0xFFf1f5eb), highlightColor: Colors.white30, duration: Duration(milliseconds: 1000), ), child: snapshot.hasData && snapshot.data != null ? Image.file( snapshot.data!, height: 80, width: 100, fit: BoxFit.cover, cacheHeight: 180, cacheWidth: 320, filterQuality: FilterQuality.low, gaplessPlayback: true, errorBuilder: (context, error, stackTrace) { return Image.asset( 'assets/image/default_thumbnail.jpg', height: 80, width: 100, fit: BoxFit.cover, ); }, ) : Image.asset( 'assets/image/default_thumbnail.jpg', height: 80, width: 100, fit: BoxFit.cover, alignment: Alignment.center, cacheHeight: 160, cacheWidth: 200, filterQuality: FilterQuality.low, gaplessPlayback: true, ), ); }, ); } // Modified to handle highlighting efficiently Future> _getHighlightedSpansAsync( String text, String searchText) { if (searchText.isEmpty) { return Future.value([TextSpan(text: text)]); } // The text is already clean of HTML, we can use it directly final String plainText = text; // If the text is very short, don't process it if (plainText.length < 3) { return Future.value([TextSpan(text: plainText)]); } // Cache for results to avoid recalculations final String cacheKey = '$plainText:$searchText'; if (_highlightedSpansCache.containsKey(cacheKey)) { return Future.value(_highlightedSpansCache[cacheKey]!); } // Run the highlighting in a compute function to avoid blocking the UI return compute(_computeHighlightedSpans, [plainText, searchText]) .then((spans) { _highlightedSpansCache[cacheKey] = spans; return spans; }).catchError((error) { // Fallback in case of error return [TextSpan(text: plainText)]; }); } // Static method for compute static List _computeHighlightedSpans(List params) { final String plainText = params[0]; final String searchText = params[1]; final List spans = []; // Extract keywords from the search and filter very short ones final List keywords = searchText .trim() .toLowerCase() .split(RegExp(r'\s+')) .where((word) => word.isNotEmpty && word.length >= 3) .toList(); if (keywords.isEmpty) { return [TextSpan(text: plainText)]; } // Text in lowercase for comparisons final String lowerText = plainText.toLowerCase(); // Create a list of all matches final List<_Match> allMatches = []; // Use a more efficient approach to find matches for (final keyword in keywords) { // For words longer than 4 letters, consider the root as the first n-1 letters String baseWord = keyword; if (baseWord.length > 4) { baseWord = baseWord.substring(0, baseWord.length - 1); } // Search for the base word in the text int startIndex = 0; while (true) { final int index = lowerText.indexOf(baseWord, startIndex); if (index == -1) break; // Find the end of the word int endIndex = index + baseWord.length; while (endIndex < lowerText.length && _isWordCharacter(lowerText[endIndex])) { endIndex++; } // Add the match allMatches.add(_Match( start: index, end: endIndex, text: plainText.substring(index, endIndex), )); // Continue from the end of this match startIndex = endIndex; } } // If there are no matches, return the original text if (allMatches.isEmpty) { return [TextSpan(text: plainText)]; } // Sort matches by position allMatches.sort((a, b) => a.start.compareTo(b.start)); // Handle overlapping matches final List<_Match> mergedMatches = []; if (allMatches.isNotEmpty) { _Match current = allMatches.first; for (int i = 1; i < allMatches.length; i++) { final _Match next = allMatches[i]; if (current.end >= next.start) { // Matches overlap, merge them current = _Match( start: current.start, end: math.max(current.end, next.end), text: plainText.substring( current.start, math.max(current.end, next.end)), ); } else { // No overlap, add the current one to the result and move to the next mergedMatches.add(current); current = next; } } mergedMatches.add(current); // Add the last match } // Build spans from merged matches int lastIndex = 0; for (final match in mergedMatches) { if (match.start > lastIndex) { spans.add(TextSpan( text: plainText.substring(lastIndex, match.start), )); } spans.add(TextSpan( text: plainText.substring(match.start, match.end), style: const TextStyle(backgroundColor: Color(0xFFfff930)), )); lastIndex = match.end; } // Add remaining text if (lastIndex < plainText.length) { spans.add(TextSpan( text: plainText.substring(lastIndex), )); } return spans; } // Check if a character is part of a word static bool _isWordCharacter(String char) { return RegExp(r'[a-zñáéíóúüA-ZÑÁÉÍÓÚÜ0-9]').hasMatch(char); } // Caché para los spans resaltados final Map> _highlightedSpansCache = {}; String _getCountryName(String countryCode) { if (countryCode.isEmpty) { return 'N/A'; } try { return CountryCodes.detailsFromAlpha2(countryCode).name.toString(); } catch (e) { // Si no se encuentra el código de país, devolver el código como está return countryCode; } } // Widget optimizado para texto de ubicación Widget _buildLocationText(Draft message) { // Evitar cálculos costosos en cada reconstrucción final String locationText = message.city.isNotEmpty ? '${message.city}, ${_getCountryNameCached(message.country)}' : _getCountryNameCached(message.country); return Text( locationText, style: const TextStyle(fontSize: 14, height: 1.2), overflow: TextOverflow.ellipsis, maxLines: 1, ); } // Versión en caché de _getCountryName String _getCountryNameCached(String countryCode) { if (countryCode.isEmpty) { return 'N/A'; } if (_countryNameCache.containsKey(countryCode)) { return _countryNameCache[countryCode]!; } try { final name = CountryCodes.detailsFromAlpha2(countryCode).name.toString(); _countryNameCache[countryCode] = name; return name; } catch (e) { _countryNameCache[countryCode] = countryCode; return countryCode; } } @override Widget build(BuildContext context) { return BaseScreen( title: 'search'.tr(), showSearchBar: true, showSettingsButton: true, searchController: _searchController, onSearchChanged: (_) {}, onSearchSubmitted: _onSearch, searchHintText: 'search_placeholder'.tr(), child: Stack( children: [ // Logo cuando no hay búsqueda activa if (!_isSearching || _currentQuery.isEmpty) Positioned( bottom: 80.0, left: 0, right: 0, child: Center( child: Image.asset( 'assets/image/logo.png', width: 200, ), ), ), // Resultados de búsqueda if (_isSearching) Padding( padding: const EdgeInsets.all(20.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildSearchHeader(), const SizedBox(height: 16), Expanded( child: PagedListView( pagingController: _pagingController, scrollController: _scrollController, addAutomaticKeepAlives: false, addRepaintBoundaries: true, physics: const AlwaysScrollableScrollPhysics(), builderDelegate: PagedChildBuilderDelegate( itemBuilder: (context, message, index) => _buildDefaultItem(message), firstPageProgressIndicatorBuilder: (_) => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ AnimatedBuilder( animation: _pulseAnimation, builder: (context, child) { return Transform.scale( scale: _pulseAnimation.value, child: child, ); }, child: const CircularProgressIndicator( color: Color(0XFF6b8e23), strokeWidth: 3, ), ), const SizedBox(height: 16), Container( padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 10), decoration: BoxDecoration( color: const Color(0xFF6b8e23).withOpacity(0.15), borderRadius: BorderRadius.circular(12), border: Border.all( color: const Color(0xFF6b8e23) .withOpacity(0.3), width: 1, ), ), child: Text( mimirLoading ? 'searching_in_progress'.tr() : 'loading_details'.tr(), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF6b8e23), ), ), ), // Mostrar el contador de resultados tan pronto como tengamos los IDs de Mimir // sin esperar a que se carguen los detalles if (!mimirLoading && totalResults > 0) Padding( padding: const EdgeInsets.only(top: 8), child: Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8), decoration: BoxDecoration( color: const Color(0xFF6b8e23) .withOpacity(0.15), borderRadius: BorderRadius.circular(12), border: Border.all( color: const Color(0xFF6b8e23) .withOpacity(0.3), width: 1, ), ), child: Text( "$totalResults ${'results'.plural(totalResults)} ${'found'.tr()}", style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF6b8e23), ), ), ), ), ], ), ), newPageProgressIndicatorBuilder: (_) => Container( padding: const EdgeInsets.symmetric(vertical: 16), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 10), decoration: BoxDecoration( color: const Color(0xFF6b8e23).withOpacity(0.15), borderRadius: BorderRadius.circular(12), border: Border.all( color: const Color(0xFF6b8e23) .withOpacity(0.3), width: 1, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ AnimatedBuilder( animation: _pulseAnimation, builder: (context, child) { return Transform.scale( scale: _pulseAnimation.value, child: child, ); }, child: const SizedBox( width: 14, height: 14, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( Color(0xFF6b8e23)), ), ), ), const SizedBox(width: 10), Text( 'loading_more'.tr(), style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: Color(0xFF6b8e23), ), ), ], ), ), ], ), ), noItemsFoundIndicatorBuilder: (_) => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: const Color(0xFF6b8e23).withOpacity(0.1), shape: BoxShape.circle, ), child: const Icon( Icons.search_off_rounded, size: 48, color: Color(0XFF6b8e23), ), ), const SizedBox(height: 16), Container( padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 12), decoration: BoxDecoration( color: const Color(0xFF6b8e23).withOpacity(0.15), borderRadius: BorderRadius.circular(12), border: Border.all( color: const Color(0xFF6b8e23) .withOpacity(0.3), width: 1, ), ), child: Text( 'no_results'.tr(), style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF6b8e23), ), ), ), const SizedBox(height: 8), Padding( padding: const EdgeInsets.symmetric(horizontal: 40), child: Text( 'try_different_search'.tr(), textAlign: TextAlign.center, style: TextStyle( fontSize: 14, color: Colors.grey[700], ), ), ), ], ), ), ), ), ), ], ), ), ], ), ); } // Widget para el encabezado de búsqueda Widget _buildSearchHeader() { Widget loadingIndicator(String text) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: const Color(0xFF6b8e23).withOpacity(0.15), borderRadius: BorderRadius.circular(12), border: Border.all( color: const Color(0xFF6b8e23).withOpacity(0.3), width: 1, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ AnimatedBuilder( animation: _pulseAnimation, builder: (context, child) { return Transform.scale( scale: _pulseAnimation.value, child: child, ); }, child: const SizedBox( width: 14, height: 14, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Color(0xFF6b8e23)), ), ), ), const SizedBox(width: 8), Text( text, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: Color(0xFF6b8e23), ), ), ], ), ); } return Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( '${'searching'.tr()}: "$_currentQuery"', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), // Mostrar el contador de resultados tan pronto como tengamos los IDs de Mimir // sin esperar a que se carguen los detalles if (!mimirLoading && totalResults > 0) Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4), decoration: BoxDecoration( color: const Color(0xFF6b8e23).withOpacity(0.15), borderRadius: BorderRadius.circular(8), ), child: Text( "$totalResults ${'results'.plural(totalResults)}", style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: Color(0xFF6b8e23), ), ), ), ], ), ], ), ), if (mimirLoading) loadingIndicator('searching_in_progress'.tr()) else if (resultsLoading && totalResults > 0) loadingIndicator('loading_details'.tr()), ], ); } Widget _buildDefaultItem(Draft message) { // Usar un widget de construcción diferida para mejorar el rendimiento return RepaintBoundary( child: Card( key: ValueKey( 'search_result_${message.id}_${message.date.millisecondsSinceEpoch}'), color: const Color(0xFFdfe6ce), margin: const EdgeInsets.only(bottom: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: InkWell( onTap: () => nav.pushScreenWithoutNavBar( context, TextViewer( data: message, searchTerm: message.body != null ? _getMatchedText(message.body!, _currentQuery) : _currentQuery)), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: Container( width: 100, height: 80, decoration: const BoxDecoration( color: Color(0xFFf1f5eb), ), child: _buildThumbnail(message.thumbnail), ), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildItemTitle(message), const SizedBox(height: 5), Text( utils.formatDate( message.date, context.locale.toString()), style: const TextStyle(fontSize: 14, height: 1), ), const SizedBox(height: 5), _buildLocationText(message), const SizedBox(height: 5), Text( "${plural('activity', 1)} ${message.activity}", style: const TextStyle(fontSize: 14, height: 1), ), ], ), ), ], ), if (message.body != null && message.body!.isNotEmpty) _buildSnippet(message.body!, _currentQuery), ], ), ), ), ), ); } // Widget optimizado para el título del item Widget _buildItemTitle(Draft message) { if (message.title.isEmpty) { return Text( utils.formatDate(message.date, context.locale.toString()), style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, height: 1.2, ), maxLines: 2, overflow: TextOverflow.ellipsis, ); } return _buildHighlightedText( message.title, _currentQuery, const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, height: 1.2, color: Colors.black, fontFamily: 'Outfit', ), maxLines: 2, ); } // Widget optimizado para el snippet Widget _buildSnippet(String snippet, String query) { return Padding( padding: const EdgeInsets.only(top: 16), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: Colors.white.withOpacity(0.5), borderRadius: BorderRadius.circular(8), ), child: _buildHighlightedText( snippet, query, const TextStyle( fontSize: 14, color: Colors.black87, height: 1.5, fontFamily: 'Outfit', ), maxLines: 3, ), ), ); } // Widget optimizado para texto resaltado Widget _buildHighlightedText(String text, String searchText, TextStyle style, {int maxLines = 2}) { // Use simple text display initially to prevent blocking Widget simpleText = RichText( maxLines: maxLines, overflow: TextOverflow.ellipsis, text: TextSpan( text: text, style: style, ), textScaler: TextScaler.linear(1.0), ); // If text is simple or search is empty, just show as is if (text.length < 50 || searchText.isEmpty) { return simpleText; } // For complex highlighting, use an optimized approach with caching and fallback final String cacheKey = '$text:$searchText'; if (_highlightedSpansCache.containsKey(cacheKey)) { // Use cached spans if available return RichText( maxLines: maxLines, overflow: TextOverflow.ellipsis, text: TextSpan( children: _highlightedSpansCache[cacheKey], style: style, ), textScaler: TextScaler.linear(1.0), ); } // Trigger async highlighting and use simple text initially _getHighlightedSpansAsync(text, searchText).then((spans) { // Force a rebuild after spans are computed if (mounted) setState(() {}); }); return simpleText; } String _getMatchedText(String content, String searchQuery) { if (searchQuery.isEmpty) return ''; // Convertir a minúsculas para búsqueda insensible a mayúsculas/minúsculas final lowerContent = content.toLowerCase(); final lowerQuery = searchQuery.toLowerCase(); // Buscar la coincidencia exacta final int index = lowerContent.indexOf(lowerQuery); if (index != -1) { // Devolver el texto original que coincidió, no la versión en minúsculas return content.substring(index, index + searchQuery.length); } // Si no hay coincidencia exacta, buscar palabras individuales final queryWords = lowerQuery .split(RegExp(r'\s+')) .where((word) => word.length > 2) .toList(); for (final word in queryWords) { final wordIndex = lowerContent.indexOf(word); if (wordIndex != -1) { // Devolver la primera palabra que coincida return content.substring(wordIndex, wordIndex + word.length); } } return searchQuery; // Fallback al término de búsqueda original } }