md-app/lib/screens/generic_search.dart

1335 lines
46 KiB
Dart

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<String, List<Draft>> _cache = LinkedHashMap();
final Map<String, int> _totalResultsCache = {};
// Almacena todos los resultados de una búsqueda
final Map<String, List<Draft>> _allResultsCache = {};
SearchCache({int maxSize = 10}) : _maxSize = maxSize;
// Obtener resultados del caché
List<Draft>? 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<Draft> 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<Draft> results) {
final normalizedQuery = query.trim().toLowerCase();
_allResultsCache[normalizedQuery] = results;
}
// Obtener todos los resultados de una búsqueda
List<Draft>? 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<List<Draft>> 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<GenericSearchPage>
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<double> _fadeAnimation;
// Controlador para la animación de pulso del indicador de búsqueda
late AnimationController _pulseController;
late Animation<double> _pulseAnimation;
// Controlador de paginación
final PagingController<int, Draft> _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<Draft>? _allSearchResults;
// Instancia del caché de búsqueda
final SearchCache _searchCache = SearchCache(maxSize: 20);
// Caché para nombres de países
final Map<String, String> _countryNameCache = {};
// Caché para los spans resaltados
final Map<String, List<TextSpan>> _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<double>(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<double>(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<void> _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<String, dynamic> searchResult = pageResults.isNotEmpty
? (pageResults.first as dynamic).searchResultData ?? {}
: {};
List<String> allResultIds = [];
if (searchResult.containsKey('allResultIds')) {
allResultIds = List<String>.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<String, Draft>>({}, (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<void> _initAppDirectory() async {
appDirectory = await getApplicationDocumentsDirectory();
}
Future<File?> _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<File?>(
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<TextSpan> _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<TextSpan> spans = [];
// Extraer palabras clave de la búsqueda y filtrar las muy cortas
final List<String> 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>(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<int, Draft>.separated(
pagingController: _pagingController,
padding: const EdgeInsets.only(bottom: 24, top: 8),
separatorBuilder: (context, index) =>
const SizedBox(height: 12),
builderDelegate: PagedChildBuilderDelegate<Draft>(
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>(
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(),
);
}
}