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 createState() => _TextViewerState(); } class _TextViewerState extends State 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 resultKeys = []; late Future _databaseFuture; bool _isSearchExpanded = false; bool _isMenuOpen = false; bool _isHtmlReady = false; final bool _isNavigating = false; Timer? _renderDebounce; String _cachedHtml = ''; bool _isFirstRender = true; final _fabKey = GlobalKey(); // Lista para almacenar las posiciones de los resultados de búsqueda List _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 _searchButtonController = ValueNotifier(''); // Nuevas variables para el enfoque basado en párrafos List _paragraphs = []; Map _paragraphKeys = {}; List _searchMatches = []; @override void initState() { super.initState(); _scrollController = ScrollController(); _databaseFuture = Future.value(AppDatabase()); _initializeData(); } Future _initializeData() async { await Future.wait([ getLocale(), checkFavorite(), _getConfig(), ]); _searchController = TextEditingController(text: widget.searchTerm); _loadMessageBody(); } Future _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 _loadMessageBodyIsolate(List 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 _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 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 _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 _downloadThumbnailIsolate( Map 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(); // 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 _checkAndDownloadThumbnail() async { setState(() { isLoading = true; }); try { final completer = Completer(); // 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 getLocale() async { final pltLocale = Platform.localeName.split('_')[0]; final savedLocale = await ConfigService.getLocale(); if (mounted) { setState(() { locale = savedLocale; }); } } Future _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>', dotAll: true); final matches = paragraphRegex.allMatches(cleanedHtml); if (matches.isEmpty) { if (kDebugMode) { print( '⚠️ No se encontraron etiquetas

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
con espacios para mantener la separación String text = html.replaceAll(RegExp(r'', caseSensitive: false), ' '); // Luego eliminar todas las demás etiquetas HTML text = text.replaceAll(RegExp(r'<[^>]*>'), ''); // Decodificar entidades HTML comunes text = text .replaceAll(' ', ' ') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll(''', "'"); // 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 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 '${match[0]}'; }); 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 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( 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 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)? 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 createState() => _OptimizedHtmlRendererState(); } class _OptimizedHtmlRendererState extends State { // Lista de resultados de búsqueda List _searchResults = []; // Mapa para almacenar las claves de los elementos renderizados final Map _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 _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 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 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 _processHtmlInIsolate( Map 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 = '${match[0]}'; 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 tags html = html.replaceAll(RegExp("face=\"[^\"]*\""), ''); // Remove tags but keep their content html = html.replaceAll(RegExp("]*>|<\\/font>"), ''); return html; }