md-app/lib/screens/content.dart

2453 lines
80 KiB
Dart

import 'dart:io';
import 'dart:async';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:country_codes/country_codes.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_inapp_notifications/flutter_inapp_notifications.dart';
import 'package:path_provider/path_provider.dart';
import 'package:percent_indicator/circular_percent_indicator.dart';
import 'package:search_engine/database.dart';
import 'package:search_engine/screens/pdf.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:photo_view/photo_view.dart';
import 'package:gal/gal.dart';
import 'package:search_engine/utils.dart' as utils;
import 'package:search_engine/services/config_service.dart';
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:search_engine/services/notification_service.dart';
// ignore: must_be_immutable
class TextViewer extends StatefulWidget {
final Draft data;
String? searchTerm;
TextViewer({super.key, required this.data, this.searchTerm});
@override
State<TextViewer> createState() => _TextViewerState();
}
class _TextViewerState extends State<TextViewer>
with SingleTickerProviderStateMixin {
bool hdThumbnail = false;
bool isLoading = true;
bool isLoadingBody = true;
String thumbnail = "";
String messageBody = "";
final _baseUrl = dotenv.env['BASE_URL'];
final _token = dotenv.env['TOKEN'];
String locale = 'es';
bool isFav = false;
Timer? _debounce;
late TextEditingController _searchController;
bool searching = false;
bool downloading = false;
int downloadProgress = 0;
ScrollController _scrollController = ScrollController();
int currentResultIndex = -1;
bool _hasShownNotification = false;
String highlightedHtml = '';
List<GlobalKey> resultKeys = [];
late Future<AppDatabase> _databaseFuture;
bool _isSearchExpanded = false;
bool _isMenuOpen = false;
bool _isHtmlReady = false;
final bool _isNavigating = false;
Timer? _renderDebounce;
String _cachedHtml = '';
bool _isFirstRender = true;
final _fabKey = GlobalKey<ExpandableFabState>();
// Lista para almacenar las posiciones de los resultados de búsqueda
List<SearchResultInfo> _searchResultPositions = [];
// Clave global para el contenedor del HTML
final GlobalKey _htmlContainerKey = GlobalKey();
// Variables para el control de búsqueda
bool _isSearching = false;
String? _highlightedResultKey;
final ValueNotifier<String> _searchButtonController =
ValueNotifier<String>('');
// Nuevas variables para el enfoque basado en párrafos
List<ParagraphData> _paragraphs = [];
Map<String, GlobalKey> _paragraphKeys = {};
List<SearchMatch> _searchMatches = [];
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_databaseFuture = Future.value(AppDatabase());
_initializeData();
}
Future<void> _initializeData() async {
await Future.wait<void>([
getLocale(),
checkFavorite(),
_getConfig(),
]);
_searchController = TextEditingController(text: widget.searchTerm);
_loadMessageBody();
}
Future<void> _loadMessageBody() async {
if (_isNavigating) return;
setState(() {
isLoadingBody = true;
_isFirstRender = true;
});
try {
// Primero cargar la imagen con timeout
await _checkAndDownloadThumbnail();
// Solo después de que la imagen esté lista, cargar el contenido
final token = RootIsolateToken.instance;
if (token == null) {
throw Exception('RootIsolateToken is not initialized');
}
final body = await compute(
_loadMessageBodyIsolate,
[widget.data.id.toString(), widget.data.languagesCode, token],
);
if (mounted) {
setState(() {
messageBody = body ?? widget.data.body ?? '';
_cachedHtml = messageBody;
isLoadingBody = false;
});
// Procesar el HTML en párrafos
_processParagraphs(messageBody);
_renderDebounce?.cancel();
_renderDebounce = Timer(const Duration(milliseconds: 100), () {
if (mounted) {
setState(() {
_isHtmlReady = true;
});
}
});
}
} catch (e) {
if (mounted) {
setState(() {
messageBody = widget.data.body ?? '';
_cachedHtml = messageBody;
isLoadingBody = false;
});
// Procesar el HTML en párrafos incluso en caso de error
_processParagraphs(messageBody);
}
}
}
static Future<String?> _loadMessageBodyIsolate(List<dynamic> params) async {
final String messageId = params[0];
final String languageCode = params[1];
final RootIsolateToken token = params[2];
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
final database = AppDatabase();
try {
final body = await database.getMessageBody(messageId, languageCode);
await database.close();
return body;
} catch (e) {
await database.close();
return null;
}
}
@override
void dispose() {
_searchController.dispose();
_scrollController.dispose();
_debounce?.cancel();
_renderDebounce?.cancel();
_databaseFuture.then((db) => db.close());
super.dispose();
}
Future<File?> _getThumbnail(String fileId) async {
final directory = await getApplicationDocumentsDirectory();
final directoryPath = '${directory.path}/LGCC_Search/$locale/thumbnails';
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' || fileName == '$fileId+HD') {
return entity;
}
}
}
return null;
}
Future<void> checkFavorite() async {
final database = await _databaseFuture;
final res = await database.checkFavorite(widget.data.id);
if (mounted) {
setState(() {
isFav = res;
});
}
}
void _toggleFavorite() async {
final database = await _databaseFuture;
await database.toggleFavorite(widget.data.id);
setState(() {
isFav = !isFav;
});
}
Future<File?> _downloadThumbnail(String url, String id,
{bool isHighDefinition = false}) async {
var appDir = await getApplicationDocumentsDirectory();
final thumbnailDir =
Directory('${appDir.path}/LGCC_Search/$locale/thumbnails/');
final fileName = '$id${isHighDefinition ? '+HD' : '+SD'}.jpg';
final existingFile = File('${thumbnailDir.path}/$fileName');
if (existingFile.existsSync()) {
return existingFile;
}
if (id == '') {
return null;
}
try {
final token = RootIsolateToken.instance;
if (token == null) {
throw Exception('RootIsolateToken is not initialized');
}
final result = await compute(
_downloadThumbnailIsolate,
{
'url': url,
'path': '${thumbnailDir.path}/$fileName',
'token': token,
},
);
if (result != null) {
final resultFile = File(result);
if (resultFile.existsSync()) {
return resultFile;
}
}
return null;
} catch (e) {
if (kDebugMode) {
print('Error downloading thumbnail: $e');
}
return null;
}
}
static Future<String?> _downloadThumbnailIsolate(
Map<String, dynamic> params) async {
final String url = params['url'];
final String path = params['path'];
final RootIsolateToken token = params['token'];
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
try {
final dio = Dio();
final completer = Completer<String?>();
// Timeout para iniciar la descarga
Timer(const Duration(seconds: 3), () {
if (!completer.isCompleted) {
completer.complete(null);
}
});
await dio.download(
url,
path,
options: Options(
responseType: ResponseType.bytes,
followRedirects: true,
receiveTimeout: const Duration(seconds: 3),
),
onReceiveProgress: (actualBytes, totalBytes) {
if (actualBytes == 0 && !completer.isCompleted) {
completer.complete(null);
}
},
);
if (!completer.isCompleted) {
completer.complete(path);
}
return await completer.future;
} catch (e) {
if (kDebugMode) {
print('Error in isolate downloading thumbnail: $e');
}
return null;
}
}
Future<void> _checkAndDownloadThumbnail() async {
setState(() {
isLoading = true;
});
try {
final completer = Completer<void>();
// Timer para el timeout de la imagen
Timer(const Duration(seconds: 5), () {
if (!completer.isCompleted) {
if (kDebugMode) {
print('Timeout loading thumbnail');
}
completer.complete();
}
});
// Intentar cargar la imagen local primero
final File? localThumbnail = await _getThumbnail(widget.data.thumbnail);
if (localThumbnail != null && localThumbnail.existsSync()) {
if (mounted) {
setState(() {
thumbnail = localThumbnail.path;
isLoading = false;
});
}
if (!completer.isCompleted) completer.complete();
return;
}
// Si no hay imagen local, intentar descargar
if (widget.data.thumbnail.isNotEmpty) {
final String url =
'$_baseUrl/assets/${widget.data.thumbnail}?access_token=$_token&format=jpg';
final File? downloadedThumbnail = await _downloadThumbnail(
url,
widget.data.thumbnail,
isHighDefinition: hdThumbnail,
);
if (mounted) {
setState(() {
thumbnail = downloadedThumbnail?.path ?? '';
isLoading = false;
});
}
} else {
if (mounted) {
setState(() {
thumbnail = '';
isLoading = false;
});
}
}
if (!completer.isCompleted) completer.complete();
return await completer.future;
} catch (e) {
if (kDebugMode) {
print('Error loading thumbnail: $e');
}
if (mounted) {
setState(() {
thumbnail = '';
isLoading = false;
});
}
}
}
Future<void> getLocale() async {
final pltLocale = Platform.localeName.split('_')[0];
final savedLocale = await ConfigService.getLocale();
if (mounted) {
setState(() {
locale = savedLocale;
});
}
}
Future<void> _getConfig() async {
hdThumbnail = await ConfigService.getHdThumbnails();
await _checkAndDownloadThumbnail();
}
void _onHtmlRendered() {
if (kDebugMode) {
print('🎨 HTML renderizado completamente');
}
if (!_isFirstRender) return;
setState(() {
_isHtmlReady = true;
_isFirstRender = false;
});
// Si hay un término de búsqueda inicial y posición, desplazarse a esa posición
if (widget.data.position > 0 && widget.data.length > 0) {
if (kDebugMode) {
print(
'🔄 Desplazándose a la posición encontrada: ${widget.data.position}');
}
// Dar tiempo para que el DOM se actualice completamente
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
_scrollToPosition(widget.data.position, widget.data.length);
}
});
}
// Si solo hay un término de búsqueda pero no posición, usar el método anterior
else if (widget.searchTerm != null && widget.searchTerm!.isNotEmpty) {
if (kDebugMode) {
print('🔄 Ejecutando búsqueda inicial: ${widget.searchTerm}');
}
_searchController.text = widget.searchTerm!;
_isSearchExpanded = true;
_onSearch();
}
// Si ya hay resultados de búsqueda, calcular sus posiciones
if (resultKeys.isNotEmpty) {
// Dar tiempo para que el DOM se actualice completamente
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
_findSearchResultPositions();
}
});
}
}
// Método para desplazarse a una posición específica en el texto
void _scrollToPosition(int position, int length) {
if (position <= 0 || !_isHtmlReady) return;
// Obtener el contexto del contenedor HTML
final BuildContext? htmlContext = _htmlContainerKey.currentContext;
if (htmlContext == null) return;
// Calcular la posición aproximada en el documento
final RenderBox? htmlBox = htmlContext.findRenderObject() as RenderBox?;
if (htmlBox == null) return;
// Estimar la posición de desplazamiento basada en la posición del texto
// Esto es una aproximación ya que no podemos mapear directamente caracteres a píxeles
final double containerHeight = htmlBox.size.height;
final String text = messageBody;
// Calcular la proporción de la posición en el texto
final double proportion = position / text.length.clamp(1, double.infinity);
// Estimar la posición de desplazamiento
final double scrollOffset = containerHeight * proportion;
// Desplazarse a la posición estimada
_scrollController.animateTo(
scrollOffset.clamp(0, _scrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
// Resaltar visualmente la sección encontrada
setState(() {
// Crear un marcador visual temporal
_highlightedResultKey = 'search-result-found';
});
// Programar la eliminación del resaltado después de un tiempo
Future.delayed(const Duration(seconds: 2), () {
if (mounted) {
setState(() {
_highlightedResultKey = null;
});
}
});
}
void _onSearch() {
if (!_isHtmlReady) {
if (kDebugMode) {
print('⚠️ HTML no está listo para búsqueda');
}
return;
}
if (kDebugMode) {
print('🔍 Iniciando búsqueda...');
print('📝 Término de búsqueda: ${_searchController.text}');
}
// Limpiar resultados anteriores
setState(() {
_searchMatches = [];
currentResultIndex = -1;
_hasShownNotification = false;
});
// Actualizar el término de búsqueda
setState(() {
widget.searchTerm = _searchController.text;
FocusManager.instance.primaryFocus?.unfocus();
if (widget.searchTerm!.isEmpty) {
return; // Exit early if search term is empty
}
});
if (_paragraphs.isNotEmpty) {
if (kDebugMode) {
print('📄 Párrafos disponibles, procediendo a buscar...');
}
_searchInParagraphs(widget.searchTerm!);
} else {
if (kDebugMode) {
print('⚠️ No hay párrafos disponibles');
}
}
}
void _showNotification() {
NotificationService().showSearchNotification(
title: 'search'.tr(),
body: 'empty_results'.tr(),
);
}
// Método para procesar el HTML en párrafos
void _processParagraphs(String html) {
_paragraphs = [];
_paragraphKeys = {};
if (kDebugMode) {
print('🔄 Procesando HTML en párrafos...');
}
// Limpiar estilos en línea antes de procesar los párrafos
final cleanedHtml = _removeFontFamily(html);
// Dividir el HTML en párrafos
final RegExp paragraphRegex = RegExp(r'<p[^>]*>(.*?)<\/p>', dotAll: true);
final matches = paragraphRegex.allMatches(cleanedHtml);
if (matches.isEmpty) {
if (kDebugMode) {
print(
'⚠️ No se encontraron etiquetas <p> en el HTML, tratando todo como un solo párrafo');
}
// Si no hay párrafos, tratar todo el contenido como un solo párrafo
final String id = 'paragraph-0';
_paragraphs.add(ParagraphData(
id: id,
content: cleanedHtml,
plainText: _stripHtml(cleanedHtml),
));
_paragraphKeys[id] = GlobalKey();
} else {
int index = 0;
for (final match in matches) {
final String paragraphHtml = match.group(0) ?? '';
if (paragraphHtml.isNotEmpty) {
final String id = 'paragraph-$index';
final String plainText = _stripHtml(paragraphHtml);
if (plainText.trim().isNotEmpty) {
_paragraphs.add(ParagraphData(
id: id,
content: paragraphHtml,
plainText: plainText,
));
_paragraphKeys[id] = GlobalKey();
index++;
}
}
}
}
if (kDebugMode) {
print('📑 Procesados ${_paragraphs.length} párrafos');
}
// Si hay un término de búsqueda inicial, realizar la búsqueda
if (widget.searchTerm != null && widget.searchTerm!.isNotEmpty) {
_searchInParagraphs(widget.searchTerm!);
}
}
// Método para eliminar etiquetas HTML y obtener texto plano
String _stripHtml(String html) {
// Primero reemplazar <br> con espacios para mantener la separación
String text =
html.replaceAll(RegExp(r'<br\s*\/?>', caseSensitive: false), ' ');
// Luego eliminar todas las demás etiquetas HTML
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
// Decodificar entidades HTML comunes
text = text
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'");
// Normalizar espacios
text = text.replaceAll(RegExp(r'\s+'), ' ').trim();
return text;
}
// Método para buscar en los párrafos
void _searchInParagraphs(String searchTerm) {
if (searchTerm.isEmpty) {
_searchMatches = [];
return;
}
_searchMatches = [];
final String lowerSearchTerm = searchTerm.toLowerCase();
if (kDebugMode) {
print('🔍 Buscando exactamente: "$searchTerm"');
}
for (int i = 0; i < _paragraphs.length; i++) {
final paragraph = _paragraphs[i];
final String lowerPlainText = paragraph.plainText.toLowerCase();
int startIndex = 0;
while (true) {
final int matchIndex =
lowerPlainText.indexOf(lowerSearchTerm, startIndex);
if (matchIndex == -1) break;
// Verificar que es una coincidencia exacta (no parte de otra palabra)
final bool isExactMatch =
true; // Siempre es exacta porque buscamos la cadena completa
if (isExactMatch) {
if (kDebugMode) {
final String matchedText = paragraph.plainText
.substring(matchIndex, matchIndex + lowerSearchTerm.length);
print(
'✓ Coincidencia encontrada en párrafo ${paragraph.id}: "$matchedText"');
}
_searchMatches.add(SearchMatch(
paragraphIndex: i,
paragraphId: paragraph.id,
startIndex: matchIndex,
endIndex: matchIndex + lowerSearchTerm.length,
matchText: paragraph.plainText
.substring(matchIndex, matchIndex + lowerSearchTerm.length),
));
}
startIndex = matchIndex + lowerSearchTerm.length;
}
}
if (kDebugMode) {
print(
'🔍 Se encontraron ${_searchMatches.length} coincidencias exactas en ${_paragraphs.length} párrafos');
// Imprimir detalles de las coincidencias para depuración
for (int i = 0; i < _searchMatches.length; i++) {
final match = _searchMatches[i];
print(' ${i + 1}. Párrafo ${match.paragraphId}: "${match.matchText}"');
}
}
// Actualizar el estado para reflejar los resultados
setState(() {
if (_searchMatches.isNotEmpty) {
currentResultIndex = 0;
} else {
currentResultIndex = -1;
if (!_hasShownNotification) {
_showNotification();
_hasShownNotification = true;
}
}
});
// Calcular las posiciones de los resultados
if (_searchMatches.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_findSearchResultPositions();
_navigateToSearchResult(0);
});
}
}
// Método para encontrar las posiciones de los resultados de búsqueda
void _findSearchResultPositions() {
if (_searchMatches.isEmpty) return;
// Obtener el contexto del contenedor HTML
final BuildContext? htmlContext = _htmlContainerKey.currentContext;
if (htmlContext == null) {
if (kDebugMode) {
print('⚠️ No se pudo obtener el contexto del contenedor HTML');
}
return;
}
// Obtener el RenderBox del contenedor HTML
final RenderBox? htmlBox = htmlContext.findRenderObject() as RenderBox?;
if (htmlBox == null) {
if (kDebugMode) {
print('⚠️ No se pudo obtener el RenderBox del contenedor HTML');
}
return;
}
// Actualizar las posiciones de los resultados
for (int i = 0; i < _searchMatches.length; i++) {
final match = _searchMatches[i];
final paragraphKey = _paragraphKeys[match.paragraphId];
if (paragraphKey?.currentContext != null) {
final RenderBox box =
paragraphKey!.currentContext!.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset.zero);
// Actualizar la posición del resultado
_searchMatches[i] = SearchMatch(
paragraphIndex: match.paragraphIndex,
paragraphId: match.paragraphId,
startIndex: match.startIndex,
endIndex: match.endIndex,
matchText: match.matchText,
rect: Rect.fromLTWH(
20, // Margen izquierdo aproximado
position.dy + 10, // Posición vertical aproximada
box.size.width - 40, // Ancho aproximado
24, // Alto aproximado
),
);
}
}
// Actualizar el estado
setState(() {});
}
// Método para navegar a un resultado específico
void _navigateToSearchResult(int index) {
if (index < 0 || index >= _searchMatches.length) return;
final match = _searchMatches[index];
final paragraphKey = _paragraphKeys[match.paragraphId];
if (kDebugMode) {
print(
'🔍 Navegando al resultado #${index + 1}: "${match.matchText}" en párrafo ${match.paragraphId}');
}
if (paragraphKey?.currentContext != null) {
// Actualizar el índice actual antes de desplazarse
setState(() {
currentResultIndex = index;
_searchButtonController.value = '${index + 1}/${_searchMatches.length}';
});
// Dar tiempo para que se actualice la UI
Future.delayed(const Duration(milliseconds: 50), () {
if (mounted) {
Scrollable.ensureVisible(
paragraphKey!.currentContext!,
alignment:
0.3, // Posicionar el párrafo en el tercio superior de la pantalla
duration: const Duration(milliseconds: 300),
);
}
});
} else {
if (kDebugMode) {
print(
'⚠️ No se pudo encontrar el contexto para el párrafo ${match.paragraphId}');
}
// Intentar actualizar las posiciones y volver a intentar
WidgetsBinding.instance.addPostFrameCallback((_) {
_findSearchResultPositions();
// Intentar nuevamente después de actualizar las posiciones
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted && paragraphKey?.currentContext != null) {
setState(() {
currentResultIndex = index;
_searchButtonController.value =
'${index + 1}/${_searchMatches.length}';
});
Scrollable.ensureVisible(
paragraphKey!.currentContext!,
alignment: 0.3,
duration: const Duration(milliseconds: 300),
);
}
});
});
}
}
// Método para ir al siguiente resultado
void nextResult() {
if (_searchMatches.isEmpty) return;
final nextIndex = (currentResultIndex + 1) % _searchMatches.length;
_navigateToSearchResult(nextIndex);
}
// Método para ir al resultado anterior
void previousResult() {
if (_searchMatches.isEmpty) return;
final prevIndex = (currentResultIndex - 1 + _searchMatches.length) %
_searchMatches.length;
_navigateToSearchResult(prevIndex);
}
// Método para resaltar texto en un párrafo
Widget _buildHighlightedParagraph(ParagraphData paragraph) {
// Si no hay búsqueda activa, mostrar el párrafo normal
if (_searchMatches.isEmpty ||
widget.searchTerm == null ||
widget.searchTerm!.isEmpty) {
return HtmlWidget(
paragraph.content,
key: _paragraphKeys[paragraph.id],
textStyle: const TextStyle(
fontSize: 16,
height: 1.5,
fontFamily: 'Arial Narrow',
),
customStylesBuilder: (element) {
return {
'margin': '0',
'padding': '0',
'line-height': '1.5',
};
},
);
}
// Buscar coincidencias en este párrafo
final List<SearchMatch> matchesInParagraph = _searchMatches
.where((match) => match.paragraphId == paragraph.id)
.toList();
if (matchesInParagraph.isEmpty) {
// Si no hay coincidencias en este párrafo, mostrar el contenido normal
return HtmlWidget(
paragraph.content,
key: _paragraphKeys[paragraph.id],
textStyle: const TextStyle(
fontSize: 16,
height: 1.5,
fontFamily: 'Arial Narrow',
),
customStylesBuilder: (element) {
return {
'margin': '0',
'padding': '0',
'line-height': '1.5',
};
},
);
}
// Hay coincidencias, crear HTML con resaltado
String highlightedContent = paragraph.content;
try {
// Reemplazar el contenido del párrafo con versiones resaltadas
final String searchTerm = widget.searchTerm!;
// Escapar caracteres especiales en el término de búsqueda para la regex
final String escapedSearchTerm = RegExp.escape(searchTerm);
// Crear una expresión regular que evite reemplazar dentro de etiquetas HTML
final regex = RegExp(
'(?<!<[^>]*?)($escapedSearchTerm)(?![^<]*?>)',
caseSensitive: false,
);
// Reemplazar todas las coincidencias con spans resaltados
highlightedContent = highlightedContent.replaceAllMapped(regex, (match) {
// Determinar si esta coincidencia es la actual
final bool isCurrentMatch = matchesInParagraph.any((m) =>
_searchMatches.indexOf(m) == currentResultIndex &&
paragraph.plainText
.toLowerCase()
.indexOf(match[0]!.toLowerCase(), m.startIndex) ==
m.startIndex);
// Usar colores diferentes para la coincidencia actual vs otras coincidencias
final String highlightColor = isCurrentMatch ? '#ffa500' : '#ffff00';
final String opacity = isCurrentMatch ? '1.0' : '0.7';
// Crear el span con el estilo adecuado
return '<span style="background-color: $highlightColor; opacity: $opacity; display: inline; padding: 2px; border-radius: 4px;">${match[0]}</span>';
});
if (kDebugMode) {
print(
'✅ Párrafo ${paragraph.id} resaltado con ${matchesInParagraph.length} coincidencias');
}
} catch (e) {
if (kDebugMode) {
print('❌ Error al resaltar párrafo ${paragraph.id}: $e');
}
// En caso de error, mostrar el contenido original
return HtmlWidget(
paragraph.content,
key: _paragraphKeys[paragraph.id],
textStyle: const TextStyle(
fontSize: 16,
height: 1.5,
fontFamily: 'Arial Narrow',
),
customStylesBuilder: (element) {
return {
'margin': '0',
'padding': '0',
'line-height': '1.5',
};
},
);
}
return HtmlWidget(
highlightedContent,
key: _paragraphKeys[paragraph.id],
textStyle: const TextStyle(
fontSize: 16,
height: 1.5,
fontFamily: 'Arial Narrow',
),
customStylesBuilder: (element) {
if (element.localName == 'span' &&
element.attributes.containsKey('style') &&
element.attributes['style']!.contains('background-color')) {
return {
'display': 'inline',
'padding': '2px',
'border-radius': '2px',
};
}
return {
'margin': '0',
'padding': '0',
'line-height': '1.5',
};
},
);
}
void _showImage() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ImageViewerScreen(
imagePath: thumbnail,
onDownload: () async {
final hasAccess = await Gal.hasAccess();
if (!hasAccess) {
await Gal.requestAccess();
} else {
try {
await Gal.putImage(thumbnail,
album: 'La Gran Carpa Catedral Corp.');
InAppNotifications.show(
title: 'image_saved'.tr(),
description: 'image_saved_desc'.tr(),
leading: const Icon(Icons.photo_library_rounded),
duration: const Duration(seconds: 3),
);
} catch (e) {
InAppNotifications.show(
title: 'error_saving_image'.tr(),
description: 'error_saving_image_desc'.tr(),
leading: const Icon(Icons.error_outline),
duration: const Duration(seconds: 3),
);
}
}
},
),
),
);
}
void scrollToTop() {
_scrollController.animateTo(0,
duration: const Duration(milliseconds: 500),
curve: Curves.fastOutSlowIn);
}
double _calculateHeight(BuildContext context) {
final isMobile = Platform.isAndroid || Platform.isIOS;
final isLandscape =
MediaQuery.of(context).orientation == Orientation.landscape;
if (isMobile) {
return isLandscape
? MediaQuery.of(context).size.height * 0.8
: MediaQuery.of(context).size.height * 0.5;
} else {
return MediaQuery.of(context).size.height;
}
}
String _getCountryName(String countryCode) {
if (countryCode.isEmpty) {
return '';
}
try {
return CountryCodes.detailsFromAlpha2(countryCode).name.toString();
} catch (e) {
return '';
}
}
void _toggleFabMenu() {
final state = _fabKey.currentState;
if (state != null) {
state.toggle();
}
}
void _closeFabMenu() {
final state = _fabKey.currentState;
if (state != null && state.isOpen) {
state.toggle();
}
}
void _openFabMenu() {
final state = _fabKey.currentState;
if (state != null && !state.isOpen) {
state.toggle();
}
}
@override
Widget build(BuildContext context) {
final bottomPadding = !isLoadingBody && messageBody.isNotEmpty && !searching
? MediaQuery.of(context).size.height * 0.12 +
MediaQuery.of(context).padding.bottom
: 16.0 + MediaQuery.of(context).padding.bottom;
final isLandscape =
MediaQuery.of(context).orientation == Orientation.landscape;
final isWideDevice = MediaQuery.of(context).size.width > 600;
final shouldUseWideLayout = isLandscape || isWideDevice;
final horizontalPadding = shouldUseWideLayout ? 40.0 : 16.0;
final textScaleFactor = shouldUseWideLayout ? 1.2 : 1.0;
return Scaffold(
backgroundColor: const Color(0xFFf1f5eb),
floatingActionButtonLocation: ExpandableFab.location,
floatingActionButton: AnimatedSlide(
duration: const Duration(milliseconds: 200),
offset: _isSearchExpanded ? const Offset(2, 0) : Offset.zero,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _isSearchExpanded ? 0.0 : 1.0,
child: !isLoadingBody && messageBody.isNotEmpty && !searching
? ExpandableFab(
key: _fabKey,
distance: 60,
type: ExpandableFabType.up,
closeButtonBuilder: FloatingActionButtonBuilder(
size: 40,
builder: (BuildContext context, void Function()? onPressed,
Animation<double> progress) {
return FloatingActionButton.small(
heroTag: 'close_fab',
onPressed: () {
_closeFabMenu();
},
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF6b8e23),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.close),
);
},
),
openButtonBuilder: DefaultFloatingActionButtonBuilder(
child: const Icon(Icons.menu),
backgroundColor: const Color(0xFF6b8e23),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
fabSize: ExpandableFabSize.small,
),
pos: ExpandableFabPos.right,
duration: const Duration(milliseconds: 150),
children: [
FloatingActionButton.small(
heroTag: 'scroll_top',
onPressed: scrollToTop,
backgroundColor: const Color(0xFF6b8e23).withAlpha(200),
child: const Icon(Icons.keyboard_arrow_up),
),
FloatingActionButton.small(
heroTag: 'search',
onPressed: () {
setState(() {
_isSearchExpanded = true;
_isMenuOpen = false;
});
},
backgroundColor: const Color(0xFF6b8e23).withAlpha(200),
child: const Icon(Icons.search),
),
],
)
: null,
),
),
body: Stack(
children: [
SingleChildScrollView(
controller: _scrollController,
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 80,
top: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
Container(
constraints:
const BoxConstraints(maxHeight: 400, minHeight: 200),
height: _calculateHeight(context),
width: double.infinity,
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
),
child: FutureBuilder<File?>(
future: thumbnail.isNotEmpty
? Future.value(File(thumbnail))
: _getThumbnail(widget.data.thumbnail),
builder: (context, snapshot) {
return Skeletonizer(
effect: const ShimmerEffect(
baseColor: Color(0xFFf1f5eb),
highlightColor: Colors.white30,
duration: Duration(milliseconds: 1000),
),
enableSwitchAnimation: true,
enabled: isLoading,
child: InkWell(
onTap: () => _showImage(),
child: Image(
image: (snapshot.hasData &&
snapshot.data != null)
? FileImage(snapshot.data!)
: const AssetImage(
'assets/image/default_thumbnail.jpg')
as ImageProvider,
alignment: Alignment.topCenter,
fit: BoxFit.cover,
width: MediaQuery.of(context).size.width,
height: _calculateHeight(context),
),
),
);
},
),
),
),
Positioned(
left: 16,
top: MediaQuery.of(context).size.height * 0.05,
child: IconButton(
icon: const Icon(Icons.arrow_back),
color: Colors.white,
onPressed: () => Navigator.pop(context),
tooltip: 'back'.tr(),
style: IconButton.styleFrom(
backgroundColor:
const Color(0xFF6b8e23).withAlpha(200),
padding: const EdgeInsets.all(10),
iconSize: 18,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
)),
),
Positioned(
right: 18,
top: MediaQuery.of(context).size.height * 0.05,
child: IconButton(
icon: Icon(
isFav ? Icons.bookmark : Icons.bookmark_border,
color: Colors.white),
onPressed: () => {_toggleFavorite()},
tooltip: 'favorite'.tr(),
style: IconButton.styleFrom(
backgroundColor: isFav
? const Color(0xFF6b8e23).withAlpha(200)
: Colors.black38,
padding: const EdgeInsets.all(10),
iconSize: 18,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
)),
),
if (downloading &&
downloadProgress > 0 &&
downloadProgress < 100)
Positioned(
right: 72,
top: MediaQuery.of(context).size.height * 0.05,
child: SizedBox(
width: 48,
height: 48,
child: CircularPercentIndicator(
backgroundColor: Colors.grey.shade400,
progressColor: const Color(0xFF6b8e23),
lineWidth: 4,
radius: 20,
center: Text(downloadProgress.toString(),
style: TextStyle(
fontSize: 14,
color: Colors.black,
shadows: [
Shadow(
color: Colors.black.withAlpha(51),
offset: const Offset(0, 1),
blurRadius: 2,
),
],
)),
percent: downloadProgress / 100,
),
),
),
Positioned(
left: shouldUseWideLayout ? 40 : 16,
bottom: 16,
right: shouldUseWideLayout ? 40 : 16,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withAlpha(128),
borderRadius: BorderRadius.circular(8),
),
child: Text(
widget.data.title.isNotEmpty
? widget.data.title
: utils.formatDate(widget.data.date, locale),
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.2,
shadows: [
Shadow(
color: Colors.black.withAlpha(51),
offset: const Offset(0, 1),
blurRadius: 2,
),
],
),
),
),
),
),
),
],
),
Padding(
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: const Color(0xFFe0e6d1),
),
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Container(
constraints: BoxConstraints(
maxWidth: (widget.data.pdf != null &&
widget.data.pdf!.isNotEmpty)
? MediaQuery.of(context).size.width *
0.6
: MediaQuery.of(context).size.width),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
utils.formatDate(
widget.data.date, locale),
style: const TextStyle(fontSize: 16)),
if (widget.data.city.isNotEmpty ||
widget.data.country.isNotEmpty)
Text(
widget.data.city.isNotEmpty
? '${widget.data.city}, ${_getCountryName(widget.data.country)}'
: _getCountryName(
widget.data.country),
style: const TextStyle(
color: Colors.black87,
fontSize: 16,
),
),
Text(
'${plural('activity', 1)} ${widget.data.activity}',
style: const TextStyle(
color: Colors.black87,
fontSize: 16,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
),
],
),
),
),
Row(
children: [
if (widget.data.pdf != null &&
widget.data.pdf!.isNotEmpty)
IconButton(
icon: const Icon(Icons.picture_as_pdf),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => FilePdf(
pdf: widget.data.pdf!,
title:
widget.data.title.isNotEmpty
? widget.data.title
: utils.formatDate(
widget.data.date,
locale),
searchTerm:
_searchController.text),
),
);
},
),
],
),
],
),
),
),
const SizedBox(height: 16),
if (isLoadingBody)
Skeletonizer(
effect: const ShimmerEffect(
baseColor: Color(0xFFf1f5eb),
highlightColor: Colors.white30,
duration: Duration(milliseconds: 1000),
),
enabled: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
height: 24,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: const Color(0xFFe0e6d1),
borderRadius: BorderRadius.circular(8),
),
),
Container(
width: double.infinity,
height: 24,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: const Color(0xFFe0e6d1),
borderRadius: BorderRadius.circular(8),
),
),
Container(
width: double.infinity,
height: 24,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: const Color(0xFFe0e6d1),
borderRadius: BorderRadius.circular(8),
),
),
Container(
width: double.infinity,
height: 24,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: const Color(0xFFe0e6d1),
borderRadius: BorderRadius.circular(8),
),
),
Container(
width: double.infinity,
height: 24,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: const Color(0xFFe0e6d1),
borderRadius: BorderRadius.circular(8),
),
),
Container(
width: double.infinity,
height: 24,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: const Color(0xFFe0e6d1),
borderRadius: BorderRadius.circular(8),
),
),
Container(
width: double.infinity,
height: 24,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: const Color(0xFFe0e6d1),
borderRadius: BorderRadius.circular(8),
),
),
],
),
),
if (!isLoadingBody && messageBody.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Stack(
children: [
// Contenido HTML principal
SelectionArea(
child: Container(
key: _htmlContainerKey,
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: _paragraphs.map((paragraph) {
return Padding(
padding:
const EdgeInsets.only(bottom: 16.0),
child: _buildHighlightedParagraph(
paragraph),
);
}).toList(),
),
),
),
],
),
),
if (!isLoadingBody && messageBody.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text(
'no_text'.tr(),
style: TextStyle(
fontSize: 16 * textScaleFactor,
color: Colors.black87,
),
),
),
),
],
),
),
],
),
),
if (!isLoadingBody && messageBody.isNotEmpty && !searching)
Positioned(
left: 0,
right: 0,
bottom: MediaQuery.of(context).viewPadding.bottom + 16,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSlide(
duration: const Duration(milliseconds: 200),
offset:
_isSearchExpanded ? Offset.zero : const Offset(0, 2),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _isSearchExpanded ? 1.0 : 0.0,
child: _isSearchExpanded
? Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16),
child: SearchBar(
searchController: _searchController,
previousResult: previousResult,
nextResult: nextResult,
onClose: () {
setState(() {
_isSearchExpanded = false;
_searchController.clear();
widget.searchTerm = '';
highlightedHtml = '';
_searchMatches = [];
currentResultIndex = -1;
});
},
onChanged: (val) {
_onSearch();
},
hasResults: _searchMatches.isNotEmpty,
currentResultIndex: currentResultIndex,
totalResults: _searchMatches.length,
),
)
: const SizedBox(),
),
),
],
),
),
],
),
);
}
}
class SearchBar extends StatelessWidget {
final TextEditingController searchController;
final VoidCallback previousResult;
final VoidCallback nextResult;
final VoidCallback onClose;
final ValueChanged<String> onChanged;
final bool hasResults;
final int currentResultIndex;
final int totalResults;
const SearchBar({
super.key,
required this.searchController,
required this.previousResult,
required this.nextResult,
required this.onChanged,
required this.onClose,
required this.hasResults,
required this.currentResultIndex,
required this.totalResults,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: const Color(0xFFe0e6d1),
borderRadius: BorderRadius.circular(10),
),
child: TextFormField(
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
hintText: 'search_placeholder'.tr(),
hintStyle: const TextStyle(color: Color(0xFF9E9E9E)),
contentPadding:
const EdgeInsets.symmetric(vertical: 10, horizontal: 15),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (hasResults) ...[
IconButton(
icon: const Icon(Icons.keyboard_arrow_left),
color: const Color(0xFF6b8e22),
onPressed: previousResult,
),
Text(
'${currentResultIndex + 1}/$totalResults',
style: const TextStyle(
color: Color(0xFF6b8e22),
fontSize: 12,
),
),
IconButton(
icon: const Icon(Icons.keyboard_arrow_right),
color: const Color(0xFF6b8e22),
onPressed: nextResult,
),
const SizedBox(width: 8),
],
IconButton(
icon: const Icon(Icons.close),
color: const Color(0xFF6b8e22),
onPressed: onClose,
),
],
),
),
controller: searchController,
onFieldSubmitted: (_) => onChanged(''),
autofocus: false,
autocorrect: true,
),
);
}
}
enum HtmlTag {
p,
span,
text,
mark,
a,
h1,
h2,
h3,
h4,
h5,
h6,
strong,
em,
i,
b,
u,
br,
ul,
ol,
li,
blockquote,
pre,
code,
img,
hr,
sup,
sub,
small,
big,
center,
font,
}
extension HtmlTagExtension on HtmlTag {
String get name {
switch (this) {
case HtmlTag.p:
return 'p';
case HtmlTag.span:
return 'span';
case HtmlTag.text:
return 'text';
case HtmlTag.mark:
return 'mark';
case HtmlTag.a:
return 'a';
case HtmlTag.h1:
return 'h1';
case HtmlTag.h2:
return 'h2';
case HtmlTag.h3:
return 'h3';
case HtmlTag.h4:
return 'h4';
case HtmlTag.h5:
return 'h5';
case HtmlTag.h6:
return 'h6';
case HtmlTag.strong:
return 'strong';
case HtmlTag.em:
return 'em';
case HtmlTag.i:
return 'i';
case HtmlTag.b:
return 'b';
case HtmlTag.u:
return 'u';
case HtmlTag.br:
return 'br';
case HtmlTag.ul:
return 'ul';
case HtmlTag.ol:
return 'ol';
case HtmlTag.li:
return 'li';
case HtmlTag.blockquote:
return 'blockquote';
case HtmlTag.pre:
return 'pre';
case HtmlTag.code:
return 'code';
case HtmlTag.img:
return 'img';
case HtmlTag.hr:
return 'hr';
case HtmlTag.sup:
return 'sup';
case HtmlTag.sub:
return 'sub';
case HtmlTag.small:
return 'small';
case HtmlTag.big:
return 'big';
case HtmlTag.center:
return 'center';
case HtmlTag.font:
return 'font';
}
}
static HtmlTag? fromString(String name) {
return HtmlTag.values.firstWhere(
(tag) => tag.name == name.toLowerCase(),
orElse: () => HtmlTag.text,
);
}
}
class OptimizedHtmlRenderer extends StatefulWidget {
final String html;
final TextStyle? textStyle;
final VoidCallback? onRendered;
final Function(List<SearchResultInfo>)? onSearchResultsFound;
final String? searchTerm;
final int? currentResultIndex;
const OptimizedHtmlRenderer({
super.key,
required this.html,
this.textStyle = const TextStyle(
fontSize: 16,
height: 1.5,
fontFamily: 'Arial Narrow',
),
this.onRendered,
this.onSearchResultsFound,
this.searchTerm,
this.currentResultIndex,
});
@override
State<OptimizedHtmlRenderer> createState() => _OptimizedHtmlRendererState();
}
class _OptimizedHtmlRendererState extends State<OptimizedHtmlRenderer> {
// Lista de resultados de búsqueda
List<SearchResultInfo> _searchResults = [];
// Mapa para almacenar las claves de los elementos renderizados
final Map<String, GlobalKey> _elementKeys = {};
// HTML procesado
String _processedHtml = '';
// Controlador de scroll
final ScrollController _scrollController = ScrollController();
// Estado de carga
bool _isLoading = true;
// Clave global para el contenedor
final GlobalKey _containerKey = GlobalKey();
// Último término de búsqueda procesado
String? _lastSearchTerm;
// Timer para actualizar posiciones
Timer? _positionUpdateTimer;
@override
void initState() {
super.initState();
_processHtml();
}
@override
void didUpdateWidget(OptimizedHtmlRenderer oldWidget) {
super.didUpdateWidget(oldWidget);
// Si el HTML cambió, volver a procesarlo
if (oldWidget.html != widget.html) {
_processHtml();
}
// Si el término de búsqueda cambió, actualizar los resultados
if (oldWidget.searchTerm != widget.searchTerm) {
// Limpiar resultados anteriores si el término está vacío
if (widget.searchTerm == null || widget.searchTerm!.isEmpty) {
setState(() {
_searchResults = [];
});
if (widget.onSearchResultsFound != null) {
widget.onSearchResultsFound!([]);
}
} else if (_lastSearchTerm != widget.searchTerm) {
_lastSearchTerm = widget.searchTerm;
_updateSearchResults();
}
}
// Si el índice actual cambió, actualizar el resaltado
if (oldWidget.currentResultIndex != widget.currentResultIndex &&
widget.currentResultIndex != null) {
_scrollToResult(widget.currentResultIndex!);
}
}
// Procesar el HTML
Future<void> _processHtml() async {
setState(() {
_isLoading = true;
// Limpiar resultados anteriores al procesar nuevo HTML
_searchResults = [];
_elementKeys.clear();
_lastSearchTerm = null;
});
try {
// Procesar el HTML en un isolate
final result = await compute(_processHtmlInIsolate, {
'html': widget.html,
'searchTerm': widget.searchTerm ?? '',
});
if (!mounted) return;
setState(() {
_processedHtml = result['html'] as String;
_isLoading = false;
});
// Notificar que el HTML se ha renderizado
WidgetsBinding.instance.addPostFrameCallback((_) {
if (widget.onRendered != null) {
widget.onRendered!();
}
// Actualizar los resultados de búsqueda
if (widget.searchTerm != null && widget.searchTerm!.isNotEmpty) {
_lastSearchTerm = widget.searchTerm;
_updateSearchResults();
}
});
} catch (e) {
if (kDebugMode) {
print('❌ Error procesando HTML: $e');
}
setState(() {
_processedHtml = widget.html;
_isLoading = false;
});
}
}
// Actualizar los resultados de búsqueda
void _updateSearchResults() {
// Cancelar cualquier timer anterior
_positionUpdateTimer?.cancel();
if (widget.searchTerm == null || widget.searchTerm!.isEmpty) {
setState(() {
_searchResults = [];
});
if (widget.onSearchResultsFound != null) {
widget.onSearchResultsFound!([]);
}
return;
}
// Limpiar resultados anteriores
setState(() {
_searchResults = [];
});
// Buscar los resultados en el DOM después de que se haya renderizado
WidgetsBinding.instance.addPostFrameCallback((_) {
// Dar tiempo para que el DOM se actualice completamente
_positionUpdateTimer = Timer(const Duration(milliseconds: 200), () {
if (mounted) {
_findSearchResultsInDom();
// Programar actualizaciones periódicas de las posiciones
// para manejar cambios en el layout
_positionUpdateTimer =
Timer.periodic(const Duration(seconds: 1), (timer) {
if (mounted && _searchResults.isNotEmpty) {
_updateSearchResultPositions();
} else {
timer.cancel();
}
});
}
});
});
}
// Encontrar los resultados de búsqueda en el DOM
void _findSearchResultsInDom() {
// Limpiar resultados anteriores
List<SearchResultInfo> newResults = [];
// Obtener el contexto del contenedor
final BuildContext? containerContext = _containerKey.currentContext;
if (containerContext == null) {
if (kDebugMode) {
print('⚠️ No se pudo obtener el contexto del contenedor');
}
return;
}
// Obtener el RenderBox del contenedor
final RenderBox? containerBox =
containerContext.findRenderObject() as RenderBox?;
if (containerBox == null || !containerBox.hasSize) {
if (kDebugMode) {
print('⚠️ No se pudo obtener el RenderBox del contenedor');
}
return;
}
// Buscar elementos con la clase 'search-result'
for (final entry in _elementKeys.entries) {
if (entry.key.startsWith('search-result-')) {
final key = entry.value;
final context = key.currentContext;
if (context != null) {
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox != null && renderBox.hasSize) {
// Calcular la posición relativa al contenedor
final Offset position =
renderBox.localToGlobal(Offset.zero, ancestor: containerBox);
final size = renderBox.size;
// Extraer el índice del ID
final index = int.tryParse(entry.key.split('-').last) ?? 0;
newResults.add(SearchResultInfo(
id: entry.key,
index: index,
rect: Rect.fromLTWH(
position.dx,
position.dy,
size.width,
size.height,
),
key: key,
));
}
}
}
}
// Ordenar los resultados por índice
newResults.sort((a, b) => a.index.compareTo(b.index));
if (kDebugMode) {
print('🔍 Se encontraron ${newResults.length} resultados de búsqueda');
}
// Actualizar el estado con los nuevos resultados
setState(() {
_searchResults = newResults;
});
// Notificar los resultados
if (widget.onSearchResultsFound != null) {
widget.onSearchResultsFound!(newResults);
}
// Si hay un índice actual, resaltar ese resultado
if (widget.currentResultIndex != null &&
widget.currentResultIndex! < newResults.length) {
_scrollToResult(widget.currentResultIndex!);
}
}
// Actualizar las posiciones de los resultados de búsqueda
void _updateSearchResultPositions() {
if (_searchResults.isEmpty) return;
// Obtener el contexto del contenedor
final BuildContext? containerContext = _containerKey.currentContext;
if (containerContext == null) return;
// Obtener el RenderBox del contenedor
final RenderBox? containerBox =
containerContext.findRenderObject() as RenderBox?;
if (containerBox == null || !containerBox.hasSize) return;
// Lista temporal para los resultados actualizados
List<SearchResultInfo> updatedResults = [];
bool positionsChanged = false;
// Actualizar cada resultado
for (final result in _searchResults) {
final context = result.key.currentContext;
if (context != null) {
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox != null && renderBox.hasSize) {
// Calcular la nueva posición
final Offset position =
renderBox.localToGlobal(Offset.zero, ancestor: containerBox);
final size = renderBox.size;
final Rect newRect = Rect.fromLTWH(
position.dx,
position.dy,
size.width,
size.height,
);
// Verificar si la posición cambió
if (newRect != result.rect) {
positionsChanged = true;
updatedResults.add(SearchResultInfo(
id: result.id,
index: result.index,
rect: newRect,
key: result.key,
));
} else {
updatedResults.add(result);
}
} else {
updatedResults.add(result);
}
} else {
updatedResults.add(result);
}
}
// Actualizar el estado solo si las posiciones cambiaron
if (positionsChanged && mounted) {
setState(() {
_searchResults = updatedResults;
});
// Notificar los resultados actualizados
if (widget.onSearchResultsFound != null) {
widget.onSearchResultsFound!(updatedResults);
}
}
}
// Hacer scroll a un resultado específico
void _scrollToResult(int index) {
if (_searchResults.isEmpty || index >= _searchResults.length) return;
final result = _searchResults[index];
final context = result.key.currentContext;
if (context != null) {
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 300),
);
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFF6b8e23),
),
);
}
return Stack(
key: _containerKey,
children: [
// Contenido HTML base
HtmlWidget(
_processedHtml,
textStyle: widget.textStyle,
customWidgetBuilder: (element) {
// Crear claves para los elementos de búsqueda
if (element.localName == 'span' &&
element.attributes.containsKey('id') &&
element.attributes['id']!.startsWith('search-result-')) {
final id = element.attributes['id']!;
_elementKeys.putIfAbsent(id, () => GlobalKey());
}
return null; // Dejar que el widget predeterminado maneje el renderizado
},
customStylesBuilder: (element) {
// Estilos base para los elementos, sin resaltado
if (element.localName == 'p' || element.localName == 'div') {
return {
'margin': '0',
'padding': '0 0 16px 0',
'font-family': 'Arial Narrow !important',
};
}
// Forzar Arial Narrow en todos los elementos que puedan tener texto
if (element.localName == 'span' ||
element.localName == 'h1' ||
element.localName == 'h2' ||
element.localName == 'h3' ||
element.localName == 'h4' ||
element.localName == 'h5' ||
element.localName == 'h6' ||
element.localName == 'strong' ||
element.localName == 'em' ||
element.localName == 'i' ||
element.localName == 'b' ||
element.localName == 'u' ||
element.localName == 'li' ||
element.localName == 'a' ||
element.localName == 'font') {
return {
'font-family': 'Arial Narrow !important',
'margin': '0',
'padding': '0',
};
}
return {
'font-family': 'Arial Narrow !important',
'margin': '0',
'padding': '0',
};
},
renderMode: RenderMode.column,
enableCaching: true,
buildAsync: true,
key: ValueKey('html-${widget.html.hashCode}'),
),
// Overlay para los resultados de búsqueda
if (_searchResults.isNotEmpty)
IgnorePointer(
child: Stack(
children: _searchResults.map((result) {
final bool isCurrentResult =
widget.currentResultIndex != null &&
result.index == widget.currentResultIndex;
return Positioned(
left: result.rect.left,
top: result.rect.top,
width: result.rect.width,
height: result.rect.height,
child: Container(
decoration: BoxDecoration(
color: isCurrentResult
? const Color(0xFFFFA500)
.withAlpha(128) // Naranjo para el actual
: const Color(0xFFFFEB3B)
.withAlpha(77), // Amarillo para los demás
borderRadius: BorderRadius.circular(2),
),
),
);
}).toList(),
),
),
],
);
}
@override
void dispose() {
_scrollController.dispose();
_positionUpdateTimer?.cancel();
super.dispose();
}
// Procesar HTML en un isolate
static Map<String, dynamic> _processHtmlInIsolate(
Map<String, dynamic> params) {
final String html = params['html'] as String;
final String searchTerm = params['searchTerm'] as String? ?? '';
if (searchTerm.isEmpty) {
return {'html': html, 'count': 0};
}
try {
// Usar un enfoque de coincidencia exacta
String processedHtml = html;
int matchCount = 0;
// Escapar el término de búsqueda para la expresión regular
final escapedSearchTerm = RegExp.escape(searchTerm);
if (kDebugMode) {
print('🔒 Término escapado: $escapedSearchTerm');
}
// Usar una expresión regular más precisa para coincidencia exacta
// que evite reemplazar dentro de etiquetas HTML
final regex = RegExp(
'(?<!<[^>]*)($escapedSearchTerm)(?![^<]*>)',
caseSensitive: false,
);
processedHtml = processedHtml.replaceAllMapped(regex, (match) {
if (kDebugMode) {
print('✨ Coincidencia exacta encontrada: ${match[0]}');
}
// Crear un marcado que sea compatible con flutter_widget_from_html
// Añadir un ID único para cada coincidencia
final result =
'<span id="search-result-$matchCount" class="search-result">${match[0]}</span>';
matchCount++;
return result;
});
if (kDebugMode) {
print('📊 Total de coincidencias exactas: $matchCount');
}
return {'html': processedHtml, 'count': matchCount};
} catch (e) {
if (kDebugMode) {
print('❌ Error durante el resaltado en isolate: $e');
}
return {'html': html, 'count': 0};
}
}
}
// Clase para almacenar información sobre un resultado de búsqueda
class SearchResultInfo {
final String id;
final int index;
final Rect rect;
final GlobalKey key;
SearchResultInfo({
required this.id,
required this.index,
required this.rect,
required this.key,
});
}
class ImageViewerScreen extends StatelessWidget {
final String imagePath;
final VoidCallback onDownload;
const ImageViewerScreen({
super.key,
required this.imagePath,
required this.onDownload,
});
// Verificar si la imagen existe y es válida
bool _isValidImage() {
if (imagePath.isEmpty) return false;
final file = File(imagePath);
return file.existsSync() && file.lengthSync() > 0;
}
// Obtener el proveedor de imagen adecuado
ImageProvider _getImageProvider() {
if (_isValidImage()) {
try {
return FileImage(File(imagePath));
} catch (e) {
if (kDebugMode) {
print('Error cargando imagen: $e');
}
return const AssetImage('assets/image/default_thumbnail.jpg');
}
} else {
return const AssetImage('assets/image/default_thumbnail.jpg');
}
}
@override
Widget build(BuildContext context) {
final imageProvider = _getImageProvider();
return Scaffold(
backgroundColor: Colors.transparent,
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
actions: [
if ((Platform.isAndroid || Platform.isIOS) && _isValidImage())
IconButton(
icon: const Icon(Icons.download, color: Colors.white),
onPressed: onDownload,
),
],
),
body: Stack(
fit: StackFit.expand,
children: [
// Imagen de fondo borrosa
Image(
image: imageProvider,
fit: BoxFit.cover,
color: Colors.black.withAlpha(128),
colorBlendMode: BlendMode.darken,
errorBuilder: (context, error, stackTrace) {
if (kDebugMode) {
print('Error mostrando imagen de fondo: $error');
}
return Container(color: Colors.black);
},
),
// Efecto de blur nativo
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
color: Colors.black.withAlpha(77),
),
),
),
// Imagen principal
Center(
child: PhotoView(
imageProvider: imageProvider,
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 2,
initialScale: PhotoViewComputedScale.contained,
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
errorBuilder: (context, error, stackTrace) {
if (kDebugMode) {
print('Error mostrando imagen principal: $error');
}
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.broken_image,
size: 64, color: Colors.white70),
const SizedBox(height: 16),
Text(
'No se pudo cargar la imagen',
style: TextStyle(color: Colors.white70),
),
],
),
);
},
),
),
],
),
);
}
}
// Clase para almacenar información sobre un párrafo
class ParagraphData {
final String id;
final String content; // HTML original
final String plainText; // Texto sin etiquetas HTML
ParagraphData({
required this.id,
required this.content,
required this.plainText,
});
}
// Clase para almacenar información sobre una coincidencia de búsqueda
class SearchMatch {
final int paragraphIndex;
final String paragraphId;
final int startIndex;
final int endIndex;
final String matchText;
final Rect? rect;
SearchMatch({
required this.paragraphIndex,
required this.paragraphId,
required this.startIndex,
required this.endIndex,
required this.matchText,
this.rect,
});
}
String _removeFontFamily(String html) {
// Remove inline styles with font-family
html = html.replaceAll(RegExp("font-family:[^;\"']*;?"), '');
// Remove face attributes from <font> tags
html = html.replaceAll(RegExp("face=\"[^\"]*\""), '');
// Remove <font> tags but keep their content
html = html.replaceAll(RegExp("<font[^>]*>|<\\/font>"), '');
return html;
}