1579 lines
53 KiB
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
|
|
}
|
|
}
|