md-app/lib/screens/search.dart

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