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/services.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:search_engine/utils.dart' as utils; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart' as nav; // ignore: library_prefixes import 'package:search_engine/services/notification_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 = {}; 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); } _cache[normalizedQuery] = results; } // Limpiar el caché void clear() { _cache.clear(); _totalResultsCache.clear(); _allResultsCache.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]; } } class GenericSearchPage extends StatefulWidget { final String? searchTerm; final String title; final String hintText; final Future> Function(String query, int pageKey, int offset) searchFunction; final Widget Function(Draft message, String currentQuery)? customItemBuilder; final Widget? emptyStateWidget; final Widget? loadingWidget; final Widget? errorWidget; const GenericSearchPage({ super.key, this.searchTerm, required this.title, required this.hintText, required this.searchFunction, this.customItemBuilder, this.emptyStateWidget, this.loadingWidget, this.errorWidget, }); @override _GenericSearchPageState createState() => _GenericSearchPageState(); } class _GenericSearchPageState extends State with TickerProviderStateMixin { late TextEditingController _searchController; late AppDatabase database; late Directory appDirectory; int resultCount = 0; int totalResults = 0; bool isLoading = false; // Nuevas variables para separar las fases de carga bool dataLoading = false; // Para la fase de carga de datos 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, // Set invisibleItemsThreshold to load the next page earlier invisibleItemsThreshold: 5, ); // Tamaño de página para la carga incremental static const _pageSize = 20; // 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); // Caché para nombres de países final Map _countryNameCache = {}; // Caché para los spans resaltados final Map> _highlightedSpansCache = {}; @override void initState() { super.initState(); _searchController = TextEditingController(text: widget.searchTerm); _initAppDirectory(); database = AppDatabase(); _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(); 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; dataLoading = 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; dataLoading = 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; dataLoading = 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(); } } }); } Future _fetchPage(int pageKey) async { if (!mounted) return; try { // Verificar si los resultados están en caché 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); } WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { resultCount = _pagingController.itemList?.length ?? 0; }); } }); return; } // Calcular el offset y límite para la paginación final offset = (pageKey - 1) * _pageSize; final limit = _pageSize; // Indicar que estamos cargando datos if (pageKey == 1) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { isLoading = true; dataLoading = true; }); } }); } // Get the RootIsolateToken before starting the search final token = RootIsolateToken.instance; if (token == null) { throw Exception('RootIsolateToken is not initialized'); } // Si ya tenemos todos los resultados, usamos paginación local if (_allSearchResults != null) { final int start = offset; final int end = math.min(start + limit, _allSearchResults!.length); if (start >= _allSearchResults!.length) { // No hay más resultados _pagingController.appendLastPage([]); } else { final pageResults = _allSearchResults!.sublist(start, end); final bool isLastPage = end >= _allSearchResults!.length; if (isLastPage) { _pagingController.appendLastPage(pageResults); } else { _pagingController.appendPage(pageResults, pageKey + 1); } // Actualizar el conteo de resultados WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { isLoading = false; dataLoading = false; totalResults = _allSearchResults!.length; resultCount = (_pagingController.itemList?.length ?? 0); }); } }); } return; } // Si no tenemos los resultados completos, realizar la búsqueda final searchFuture = widget.searchFunction(_currentQuery, pageKey, offset); final pageResults = await searchFuture; // Si es la primera página, cargar todos los resultados if (pageKey == 1) { // Obtener todos los resultados completos (no solo la primera página) try { // Extraer la lista de IDs de todos los resultados Map searchResult = pageResults.isNotEmpty ? (pageResults.first as dynamic).searchResultData ?? {} : {}; List allResultIds = []; if (searchResult.containsKey('allResultIds')) { allResultIds = List.from(searchResult['allResultIds']); } if (allResultIds.isNotEmpty) { _allSearchResults = await database.getMessagesByIds( allResultIds, context.locale.toString()); totalResults = _allSearchResults?.length ?? 0; if (_allSearchResults != null) { _searchCache.putAllResults(_currentQuery, _allSearchResults!); _searchCache.setTotalResults( _currentQuery, _allSearchResults!.length); } } } catch (e) { if (kDebugMode) { print('Error cargando todos los resultados: $e'); } // Continuar con la paginación normal si falla _allSearchResults = null; } } // Si no pudimos cargar todos los resultados, continuar con la paginación normal if (_allSearchResults == null) { // Obtener resultados únicos por ID de documento final uniqueResults = pageResults .fold>({}, (map, result) { if (!map.containsKey(result.id)) { map[result.id] = result; } return map; }) .values .toList(); // Actualizar el total de resultados si es la primera página if (pageKey == 1) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { totalResults = uniqueResults.length; dataLoading = false; }); } }); } // Guardar esta página en caché _searchCache.put('${_currentQuery}_page_$pageKey', uniqueResults); // Mostrar los resultados final isLastPage = uniqueResults.length < _pageSize; if (isLastPage) { _pagingController.appendLastPage(uniqueResults); } else { _pagingController.appendPage(uniqueResults, pageKey + 1); } } else { // Usar los resultados completos para la primera página final int start = 0; final int end = math.min(limit, _allSearchResults!.length); final firstPageResults = _allSearchResults!.sublist(start, end); // Guardar esta página en caché _searchCache.put('${_currentQuery}_page_1', firstPageResults); final bool isLastPage = end >= _allSearchResults!.length; if (isLastPage) { _pagingController.appendLastPage(firstPageResults); } else { _pagingController.appendPage(firstPageResults, pageKey + 1); } } // Actualizar el estado después del frame WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { isLoading = false; dataLoading = false; resultCount = (_pagingController.itemList?.length ?? 0); }); if (pageKey == 1) { if (_animationController.isCompleted) { _animationController.reset(); } _animationController.forward(); } } }); } catch (e) { if (kDebugMode) { print('Error en paginación: $e'); } WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { isLoading = false; dataLoading = false; }); _pagingController.error = e; } }); } } 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) { return FutureBuilder( future: _getThumbnail( '${appDirectory.path}/LGCC_Search/${context.locale}/thumbnails/', thumbnailId), builder: (context, snapshot) { return Skeletonizer( 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: double.infinity, width: double.infinity, fit: BoxFit.cover, cacheHeight: 180, cacheWidth: 320, ) : Image.asset( 'assets/image/default_thumbnail.jpg', height: double.infinity, width: double.infinity, fit: BoxFit.cover, alignment: Alignment.topCenter, cacheHeight: 180, cacheWidth: 320, ), ); }, ); } List _getHighlightedSpans(String text, String searchText) { if (searchText.isEmpty) { return [TextSpan(text: text)]; } // El texto ya está limpio de HTML, podemos usarlo directamente final String plainText = text; // Si el texto es muy corto, no procesarlo if (plainText.length < 3) { return [TextSpan(text: plainText)]; } // Caché de resultados para evitar recálculos final String cacheKey = '$plainText:$searchText'; if (_highlightedSpansCache.containsKey(cacheKey)) { return _highlightedSpansCache[cacheKey]!; } final List spans = []; // Extraer palabras clave de la búsqueda y filtrar las muy cortas 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)]; } // Texto en minúsculas para comparaciones final String lowerText = plainText.toLowerCase(); // Crear una lista de todas las coincidencias final List<_Match> allMatches = []; // Usar un enfoque más eficiente para encontrar coincidencias for (final keyword in keywords) { // Para palabras de más de 4 letras, considerar la raíz como las primeras n-1 letras String baseWord = keyword; if (baseWord.length > 4) { baseWord = baseWord.substring(0, baseWord.length - 1); } // Buscar la palabra base en el texto int startIndex = 0; while (true) { final int index = lowerText.indexOf(baseWord, startIndex); if (index == -1) break; // Encontrar el final de la palabra int endIndex = index + baseWord.length; while (endIndex < lowerText.length && _isWordCharacter(lowerText[endIndex])) { endIndex++; } // Añadir la coincidencia allMatches.add(_Match( start: index, end: endIndex, text: plainText.substring(index, endIndex), )); // Continuar desde el final de esta coincidencia startIndex = endIndex; } } // Si no hay coincidencias, devolver el texto original if (allMatches.isEmpty) { return [TextSpan(text: plainText)]; } // Ordenar coincidencias por posición allMatches.sort((a, b) => a.start.compareTo(b.start)); // Manejar coincidencias superpuestas 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) { // Las coincidencias se superponen, fusionarlas current = _Match( start: current.start, end: math.max(current.end, next.end), text: plainText.substring( current.start, math.max(current.end, next.end)), ); } else { // Sin superposición, agregar la actual al resultado y pasar a la siguiente mergedMatches.add(current); current = next; } } mergedMatches.add(current); // Agregar la última coincidencia } // Construir spans a partir de coincidencias fusionadas 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; } // Agregar texto restante if (lastIndex < plainText.length) { spans.add(TextSpan( text: plainText.substring(lastIndex), )); } // Guardar en caché para uso futuro _highlightedSpansCache[cacheKey] = spans; return spans; } // Verificar si un carácter es parte de una palabra bool _isWordCharacter(String char) { return RegExp(r'[a-zñáéíóúüA-ZÑÁÉÍÓÚÜ0-9]').hasMatch(char); } // Widget optimizado para texto resaltado Widget _buildHighlightedText(String text, String searchText, TextStyle style, {int maxLines = 2}) { return RichText( maxLines: maxLines, overflow: TextOverflow.ellipsis, text: TextSpan( children: _getHighlightedSpans(text, searchText), style: style, ), textScaler: TextScaler.linear(1.0), ); } // 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; } } 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 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 datos if (!dataLoading && 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 (dataLoading) loadingIndicator('searching_in_progress'.tr()), ], ); } @override Widget build(BuildContext context) { return BaseScreen( title: widget.title, showSearchBar: true, showSettingsButton: true, searchController: _searchController, onSearchChanged: _onSearch, onSearchSubmitted: (query) => _onSearch(query), searchHintText: widget.hintText, returnButton: true, 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: Expanded( child: PagedListView.separated( pagingController: _pagingController, padding: const EdgeInsets.only(bottom: 24, top: 8), separatorBuilder: (context, index) => const SizedBox(height: 12), builderDelegate: PagedChildBuilderDelegate( itemBuilder: (context, message, index) => widget.customItemBuilder != null ? widget.customItemBuilder!( message, _currentQuery) : _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( 'searching_in_progress'.tr(), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF6b8e23), ), ), ), // Mostrar el contador de resultados tan pronto como tengamos los datos if (!dataLoading && 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: (_) => widget.emptyStateWidget ?? 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], ), ), ), ], ), ), firstPageErrorIndicatorBuilder: (context) => widget.errorWidget ?? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon( Icons.error_outline, size: 64, color: Colors.red, ), const SizedBox(height: 16), Text( 'search_error'.tr(), style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), ElevatedButton( onPressed: () => _pagingController.refresh(), child: Text('retry'.tr()), ), ], ), ), ), // Configure to load the next page earlier scrollDirection: Axis.vertical, shrinkWrap: true, addAutomaticKeepAlives: true, addRepaintBoundaries: true, // Add these properties to make pagination more responsive addSemanticIndexes: false, physics: const AlwaysScrollableScrollPhysics(), ), ), ), ], ), ), ], ), ); } 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 } void _showNotification() { NotificationService().showSearchNotification( title: 'search'.tr(), body: 'empty_results'.tr(), ); } }