1335 lines
46 KiB
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(),
|
|
);
|
|
}
|
|
}
|