diff --git a/.gradle/config.properties b/.gradle/config.properties new file mode 100644 index 0000000..978d1a0 --- /dev/null +++ b/.gradle/config.properties @@ -0,0 +1,2 @@ +#Sun Jan 12 11:55:42 COT 2025 +java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..3f50dd0 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "search_engine", + "request": "launch", + "type": "dart" + }, + { + "name": "search_engine (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "search_engine (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..e4e6432 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,64 @@ +plugins { + id "com.android.application" + // START: FlutterFire Configuration + id 'com.google.gms.google-services' + // END: FlutterFire Configuration + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file("local.properties") +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader("UTF-8") { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty("flutter.versionCode") +if (flutterVersionCode == null) { + flutterVersionCode = "1" +} + +def flutterVersionName = localProperties.getProperty("flutter.versionName") +if (flutterVersionName == null) { + flutterVersionName = "1.0" +} + +android { + namespace = "com.carpa.search_engine" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + lintOptions { + disable 'Deprecation' + } + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.carpa.search_engine" + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. + minSdk = 24 + targetSdk = flutter.targetSdkVersion + versionCode = flutterVersionCode.toInteger() + versionName = flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.debug + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..be95d5f --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "289926488951", + "project_id": "carpa-search-engine", + "storage_bucket": "carpa-search-engine.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:289926488951:android:ecbbaa7efda3d6721ab772", + "android_client_info": { + "package_name": "com.carpa.search_engine" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBwkWdv6iBcKLjAPnoe0WpdpLTO4tBdqSA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/carpa/carpa/MainActivity.kt b/android/app/src/main/kotlin/com/carpa/carpa/MainActivity.kt new file mode 100644 index 0000000..7605d4a --- /dev/null +++ b/android/app/src/main/kotlin/com/carpa/carpa/MainActivity.kt @@ -0,0 +1,5 @@ +package com.carpa.carpa + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/android/app/src/main/kotlin/com/carpa/estudiosbiblicosapp/MainActivity.kt b/android/app/src/main/kotlin/com/carpa/estudiosbiblicosapp/MainActivity.kt new file mode 100644 index 0000000..a9187b9 --- /dev/null +++ b/android/app/src/main/kotlin/com/carpa/estudiosbiblicosapp/MainActivity.kt @@ -0,0 +1,5 @@ +package com.carpa.estudiosbiblicosapp + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/android/app/src/main/kotlin/com/carpa/search_engine/MainActivity.kt b/android/app/src/main/kotlin/com/carpa/search_engine/MainActivity.kt new file mode 100644 index 0000000..9248c91 --- /dev/null +++ b/android/app/src/main/kotlin/com/carpa/search_engine/MainActivity.kt @@ -0,0 +1,5 @@ +package com.carpa.search_engine + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..e328483 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,20 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} + +ext.kotlin_version = '2.1.10' \ No newline at end of file diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..9d3c664 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,29 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.1.4" apply false + // START: FlutterFire Configuration + id "com.google.gms.google-services" version "4.4.2" apply false + // END: FlutterFire Configuration + id "org.jetbrains.kotlin.android" version "2.1.10" apply false +} + +rootProject.name = 'estudiosbiblicosapp' +include ":app" \ No newline at end of file diff --git a/assets/fonts/Arial-Narrow/ArchivoNarrow-Bold.ttf b/assets/fonts/Arial-Narrow/ArchivoNarrow-Bold.ttf new file mode 100644 index 0000000..52b51a8 Binary files /dev/null and b/assets/fonts/Arial-Narrow/ArchivoNarrow-Bold.ttf differ diff --git a/assets/fonts/Arial-Narrow/ArchivoNarrow-BoldItalic.ttf b/assets/fonts/Arial-Narrow/ArchivoNarrow-BoldItalic.ttf new file mode 100644 index 0000000..0f95cf9 Binary files /dev/null and b/assets/fonts/Arial-Narrow/ArchivoNarrow-BoldItalic.ttf differ diff --git a/assets/fonts/Arial-Narrow/ArchivoNarrow-Italic.ttf b/assets/fonts/Arial-Narrow/ArchivoNarrow-Italic.ttf new file mode 100644 index 0000000..2b3f79d Binary files /dev/null and b/assets/fonts/Arial-Narrow/ArchivoNarrow-Italic.ttf differ diff --git a/assets/fonts/Arial-Narrow/ArchivoNarrow-Medium.ttf b/assets/fonts/Arial-Narrow/ArchivoNarrow-Medium.ttf new file mode 100644 index 0000000..44a931c Binary files /dev/null and b/assets/fonts/Arial-Narrow/ArchivoNarrow-Medium.ttf differ diff --git a/assets/fonts/Arial-Narrow/ArchivoNarrow-MediumItalic.ttf b/assets/fonts/Arial-Narrow/ArchivoNarrow-MediumItalic.ttf new file mode 100644 index 0000000..fa28334 Binary files /dev/null and b/assets/fonts/Arial-Narrow/ArchivoNarrow-MediumItalic.ttf differ diff --git a/assets/fonts/Arial-Narrow/ArchivoNarrow-Regular.ttf b/assets/fonts/Arial-Narrow/ArchivoNarrow-Regular.ttf new file mode 100644 index 0000000..058901b Binary files /dev/null and b/assets/fonts/Arial-Narrow/ArchivoNarrow-Regular.ttf differ diff --git a/assets/fonts/Arial-Narrow/ArchivoNarrow-SemiBold.ttf b/assets/fonts/Arial-Narrow/ArchivoNarrow-SemiBold.ttf new file mode 100644 index 0000000..5f93a1d Binary files /dev/null and b/assets/fonts/Arial-Narrow/ArchivoNarrow-SemiBold.ttf differ diff --git a/assets/fonts/Arial-Narrow/ArchivoNarrow-SemiBoldItalic.ttf b/assets/fonts/Arial-Narrow/ArchivoNarrow-SemiBoldItalic.ttf new file mode 100644 index 0000000..e7b1e7b Binary files /dev/null and b/assets/fonts/Arial-Narrow/ArchivoNarrow-SemiBoldItalic.ttf differ diff --git a/assets/fonts/Outfit-Black.ttf b/assets/fonts/Outfit-Black.ttf new file mode 100644 index 0000000..487752b Binary files /dev/null and b/assets/fonts/Outfit-Black.ttf differ diff --git a/assets/fonts/Outfit-Bold.ttf b/assets/fonts/Outfit-Bold.ttf new file mode 100644 index 0000000..0a081bc Binary files /dev/null and b/assets/fonts/Outfit-Bold.ttf differ diff --git a/assets/fonts/Outfit-ExtraBold.ttf b/assets/fonts/Outfit-ExtraBold.ttf new file mode 100644 index 0000000..0977ed5 Binary files /dev/null and b/assets/fonts/Outfit-ExtraBold.ttf differ diff --git a/assets/fonts/Outfit-ExtraLight.ttf b/assets/fonts/Outfit-ExtraLight.ttf new file mode 100644 index 0000000..938fe31 Binary files /dev/null and b/assets/fonts/Outfit-ExtraLight.ttf differ diff --git a/assets/fonts/Outfit-Light.ttf b/assets/fonts/Outfit-Light.ttf new file mode 100644 index 0000000..c18b0c1 Binary files /dev/null and b/assets/fonts/Outfit-Light.ttf differ diff --git a/assets/fonts/Outfit-Medium.ttf b/assets/fonts/Outfit-Medium.ttf new file mode 100644 index 0000000..7ae796b Binary files /dev/null and b/assets/fonts/Outfit-Medium.ttf differ diff --git a/assets/fonts/Outfit-Regular.ttf b/assets/fonts/Outfit-Regular.ttf new file mode 100644 index 0000000..826899c Binary files /dev/null and b/assets/fonts/Outfit-Regular.ttf differ diff --git a/assets/fonts/Outfit-SemiBold.ttf b/assets/fonts/Outfit-SemiBold.ttf new file mode 100644 index 0000000..6b37eeb Binary files /dev/null and b/assets/fonts/Outfit-SemiBold.ttf differ diff --git a/assets/fonts/Outfit-Thin.ttf b/assets/fonts/Outfit-Thin.ttf new file mode 100644 index 0000000..7d84201 Binary files /dev/null and b/assets/fonts/Outfit-Thin.ttf differ diff --git a/assets/image/default_thumbnail.jpg b/assets/image/default_thumbnail.jpg new file mode 100644 index 0000000..29c1406 Binary files /dev/null and b/assets/image/default_thumbnail.jpg differ diff --git a/assets/image/logo.png b/assets/image/logo.png new file mode 100644 index 0000000..d5f38b8 Binary files /dev/null and b/assets/image/logo.png differ diff --git a/assets/lang/en.json b/assets/lang/en.json new file mode 100644 index 0000000..3390772 --- /dev/null +++ b/assets/lang/en.json @@ -0,0 +1,145 @@ +{ + "title": "Bible\nStudies", + "version": "Version", + "downloading_data": "Downloading data...", + "updating_data": "Updating database...", + "success_updating_data": "Data updated successfully", + "welcome": "Home", + "home": "Bible Studies", + "search": "Search", + "searching": "Searching", + "searching_in_progress": "Searching...", + "loading_results": "Loading results...", + "loading_more": "Loading more...", + "try_different_search": "Try a different search", + "search_placeholder": "Search...", + "image_saved": "Image saved", + "image_saved_desc": "Please, check your gallery", + "error_saving_image": "Error saving image", + "error_saving_image_desc": "Please, try again later", + "results": { + "one": "Result", + "other": "Results" + }, + "results_found": { + "one": "Result found", + "other": "Results found" + }, + "calendar": "By date", + "empty_search_term": "Please enter a search term", + "empty_results": "No results found", + "library": "Library", + "config": "Settings", + "downloaded": "Downloaded", + "available": "Available", + "about": "About", + "settings": "Settings", + "lang": "Language", + "theme": "Theme", + "system": "System", + "light": "Light", + "dark": "Dark", + "auto": "Automatic", + "hide_thumbnails": "Hide thumbnails", + "download_pdf": "Download PDF", + "pdf_download": "Automatic PDF download", + "pdf_download_desc": "A PDF will be downloaded automatically if it is not available", + "hd_thumbnails_desc": "Only downloaded when entering a specific study", + "hd_thumbnails": "High-quality thumbnails", + "locale": "Language", + "confirm_locale_change": "Confirm language change", + "confirm_locale_change_desc": "Are you sure you want to change the language?", + "no": "No", + "yes": "Yes", + "cancel": "Cancel", + "accept": "Accept", + "last_activities": "Recent activities", + "recent_text": "Recent texts", + "no_text": "No transcription available", + "draft": "Draft", + "from": "from", + "full_screen": "Full screen", + "activity": { + "one": "Activity", + "other": "Activities" + }, + "back": "Back", + "favorite": "Favorite", + "year": { + "one": "Year", + "other": "Years" + }, + "month": { + "one": "Month", + "other": "Months" + }, + "day": { + "one": "Day", + "other": "Days" + }, + "hour": { + "one": "Hour", + "other": "Hours" + }, + "minute": { + "one": "Minute", + "other": "Minutes" + }, + "filter": "Filter", + "clear_filters": "Clear filters", + "select_year": "Select year", + "select_month": "Select month", + "select_year_first": "Select a year first", + "no_months_available": "No months available", + "no_activities_for_period": "No activities for this period", + "clear": "Clear", + "apply": "Apply", + "cache_storage": "Internal storage", + "cache_storage_desc": "Internal storage of the application", + "cache_storage_clear": "Clear internal storage", + "cache_storage_clear_desc": "Clear the internal storage of the application", + "cache_storage_clear_confirm": "Are you sure you want to clear the internal storage of the application?", + "cache_storage_clear_success": "Internal storage cleared successfully", + "cache_storage_clear_error": "Error clearing internal storage", + "storage_usage": "Storage Usage", + "storage_usage_desc": "Downloaded files are stored on your device for faster access", + "storage_used": "Storage Used", + "thumbnails": "Thumbnails", + "pdfs": "PDF Documents", + "other": "Other Files", + "clear_cache": "Clear Cache", + "clear_cache_desc": "Are you sure you want to delete all downloaded files? Files will be downloaded again when needed.", + "connecting_to_server": "Connecting to server...", + "server_unreachable": "Server unreachable", + "processing_data": "Processing data...", + "processed_items": "Processed {} of {} items", + "sync_complete": "Sync completed", + "sync_error": "Sync error", + "sync_title": "Synchronization", + "sync_in_progress": "Synchronization in progress", + "open_app": "Open app", + "no_new_data": "No new data", + "internal": "Internal", + "internal_desc": "Internal storage of the application", + "internal_clear": "Clear internal storage", + "internal_clear_desc": "Clear the internal storage of the application", + "internal_clear_confirm": "Are you sure you want to clear the internal storage of the application?", + "internal_clear_success": "Internal storage cleared successfully", + "internal_clear_error": "Error clearing internal storage", + "checking_database": "Checking database...", + "syncing_search_index": "Syncing search index...", + "updating_search_index": "Updating search index...", + "search_index_updated": "Search index updated", + "download_manager": "Download Manager", + "search_files": "Search files", + "select_language": "Select language", + "all_languages": "All languages", + "all": "All", + "images": "Images", + "files_found": "files found", + "no_files_found": "No downloaded files found", + "no_pdfs_found": "No PDF documents found", + "confirm_delete": "Confirm deletion", + "delete_file_confirmation": "Are you sure you want to delete the file {0}?", + "delete": "Delete" +} \ No newline at end of file diff --git a/assets/lang/es.json b/assets/lang/es.json new file mode 100644 index 0000000..3364ce5 --- /dev/null +++ b/assets/lang/es.json @@ -0,0 +1,146 @@ +{ + "title": "Estudios\nBíblicos", + "version": "Versión", + "downloading_data": "Descargando datos...", + "updating_data": "Actualizando base de datos...", + "success_updating_data": "Datos actualizados correctamente", + "welcome": "Inicio", + "home": "Estudios Bíblicos", + "search": "Búsqueda", + "searching": "Buscando", + "searching_in_progress": "Buscando...", + "loading_results": "Cargando resultados...", + "loading_more": "Cargando más...", + "try_different_search": "Intenta con otra búsqueda", + "search_placeholder": "Buscar...", + "image_saved": "Imagen guardada", + "image_saved_desc": "Por favor, revisa tu galería", + "error_saving_image": "Error al guardar la imagen", + "error_saving_image_desc": "Por favor, reintenta más tarde", + "results": { + "one": "Resultado", + "other": "Resultados" + }, + "results_found": { + "one": "Resultado encontrado", + "other": "Resultados encontrados" + }, + "calendar": "Por fecha", + "empty_search_term": "Por favor, introduzca un término de búsqueda", + "empty_results": "No se encontraron resultados", + "library": "Biblioteca", + "config": "Ajustes", + "downloaded": "Descargados", + "available": "Disponibles", + "about": "Acerca de", + "settings": "Ajustes", + "lang": "Idioma", + "theme": "Tema", + "system": "Sistema", + "light": "Claro", + "dark": "Oscuro", + "auto": "Automático", + "hide_thumbnails": "Ocultar portadas", + "download_pdf": "Descargar PDF", + "pdf_download": "Descarga automática de PDF", + "pdf_download_desc": "Al acceder a un PDF no disponible, se descargará automáticamente", + "hd_thumbnails_desc": "Sólo se descargarán al entrar a un estudio específico", + "hd_thumbnails": "Portadas en alta calidad", + "locale": "Idioma", + "confirm_locale_change": "Confirmar cambio de idioma", + "confirm_locale_change_desc": "¿Está seguro que desea cambiar de idioma?", + "no": "No", + "yes": "Sí", + "cancel": "Cancelar", + "accept": "Aceptar", + "last_activities": "Últimas actividades", + "recent_text": "Textos recientes", + "no_text": "No hay transcripción disponible", + "draft": "Borrador", + "from": "de", + "full_screen": "Pantalla completa", + "activity": { + "one": "Actividad", + "other": "Actividades" + }, + "back": "Volver", + "favorite": "Favorito", + "year": { + "one": "Año", + "other": "Años" + }, + "month": { + "one": "Mes", + "other": "Meses" + }, + "day": { + "one": "Día", + "other": "Días" + }, + "hour": { + "one": "Hora", + "other": "Horas" + }, + "minute": { + "one": "Minuto", + "other": "Minutos" + }, + "filter": "Filtrar", + "clear_filters": "Limpiar filtros", + "select_year": "Seleccionar año", + "select_month": "Seleccionar mes", + "select_year_first": "Primero selecciona un año", + "no_months_available": "No hay meses disponibles", + "no_activities_for_period": "No hay actividades para este período", + "clear": "Limpiar", + "apply": "Aplicar", + "cache_storage": "Almacenamiento interno", + "cache_storage_desc": "Almacenamiento interno de la aplicación", + "cache_storage_clear": "Limpiar almacenamiento interno", + "cache_storage_clear_desc": "Limpiar el almacenamiento interno de la aplicación", + "cache_storage_clear_confirm": "¿Está seguro que desea limpiar el almacenamiento interno de la aplicación?", + "cache_storage_clear_success": "Almacenamiento interno limpiado correctamente", + "cache_storage_clear_error": "Error al limpiar el almacenamiento interno", + "storage_usage": "Uso de almacenamiento", + "storage_usage_desc": "Los archivos descargados se almacenan en tu dispositivo para un acceso más rápido", + "storage_used": "Almacenamiento usado", + "thumbnails": "Portadas", + "pdfs": "Documentos PDF", + "other": "Otros archivos", + "clear_cache": "Limpiar caché", + "clear_cache_desc": "¿Estás seguro de que deseas eliminar todos los archivos descargados? Los archivos se volverán a descargar cuando sean necesarios.", + "connecting_to_server": "Conectando al servidor...", + "server_unreachable": "Servidor no accesible", + "processing_data": "Procesando datos...", + "processed_items": "Procesados {} de {} elementos", + "sync_complete": "Sincronización completada", + "sync_error": "Error de sincronización", + "sync_title": "Sincronización", + "sync_in_progress": "Sincronización en progreso", + "open_app": "Abrir aplicación", + "no_new_data": "No hay datos nuevos", + "internal": "Interno", + "internal_desc": "Almacenamiento interno de la aplicación", + "internal_clear": "Limpiar almacenamiento interno", + "internal_clear_desc": "Limpiar el almacenamiento interno de la aplicación", + "internal_clear_confirm": "¿Está seguro que desea limpiar el almacenamiento interno de la aplicación?", + "internal_clear_success": "Almacenamiento interno limpiado correctamente", + "internal_clear_error": "Error al limpiar el almacenamiento interno", + "checking_database": "Verificando base de datos...", + "syncing_search_index": "Sincronizando índice de búsqueda...", + "updating_search_index": "Actualizando índice de búsqueda...", + "search_index_updated": "Índice de búsqueda actualizado", + "download_manager": "Gestor de descargas", + "search_files": "Buscar archivos", + "select_language": "Seleccionar idioma", + "all_languages": "Todos los idiomas", + "all": "Todos", + "images": "Imágenes", + "files_found": "archivos encontrados", + "no_files_found": "No se encontraron archivos descargados", + "no_pdfs_found": "No se encontraron documentos PDF", + "confirm_delete": "Confirmar eliminación", + "delete_file_confirmation": "¿Estás seguro de querer eliminar el archivo {0}?", + "delete": "Eliminar", + "syncronize": "Sincronizar" +} \ No newline at end of file diff --git a/assets/lang/fr.json b/assets/lang/fr.json new file mode 100644 index 0000000..9c1cd85 --- /dev/null +++ b/assets/lang/fr.json @@ -0,0 +1,104 @@ +{ + "title": "Études\nBibliques", + "version": "Version", + "downloading_data": "Téléchargement des données...", + "updating_data": "Mise à jour de la base de données...", + "success_updating_data": "Données mises à jour avec succès", + "welcome": "Bienvenue", + "home": "Études Biblques", + "search": "Recherche", + "searching": "Recherche", + "searching_in_progress": "Recherche en cours...", + "loading_results": "Chargement des résultats...", + "loading_more": "Chargement...", + "try_different_search": "Essayez une recherche différente", + "search_placeholder": "Rechercher...", + "image_saved": "Image enregistrée", + "image_saved_desc": "Veuillez vérifier votre galerie", + "error_saving_image": "Erreur lors de l'enregistrement de l'image", + "error_saving_image_desc": "Veuillez réessayer plus tard", + "results": { + "one": "Résultat", + "other": "Résultats" + }, + "results_found": { + "one": "Résultat trouvé", + "other": "Résultats trouvés" + }, + "calendar": "Par date", + "empty_search_term": "Veuillez entrer un terme de recherche", + "empty_results": "Aucun résultat trouvé", + "library": "Bibliothèque", + "config": "Paramètres", + "downloaded": "Téléchargés", + "available": "Disponibles", + "about": "À propos", + "settings": "Paramètres", + "lang": "Langue", + "theme": "Thème", + "system": "Système", + "light": "Clair", + "dark": "Sombre", + "auto": "Automatique", + "hide_thumbnails": "Cacher les vignettes", + "download_pdf": "Télécharger le PDF", + "pdf_download": "Téléchargement automatique du PDF", + "pdf_download_desc": "Lors de l'accès à un PDF non disponible, il sera téléchargé automatiquement", + "hd_thumbnails_desc": "Elles ne seront téléchargées que lors de l'accès à une étude spécifique", + "hd_thumbnails": "Vignettes en haute qualité", + "locale": "Langue", + "confirm_locale_change": "Confirmer le changement de langue", + "confirm_locale_change_desc": "Êtes-vous sûr de vouloir changer de langue?", + "no": "Non", + "yes": "Oui", + "cancel": "Annuler", + "accept": "Accepter", + "last_activities": "Dernières activités", + "recent_text": "Textes récents", + "no_text": "Aucune transcription disponible", + "draft": "Brouillon", + "from": "de", + "full_screen": "Plein écran", + "activity": { + "one": "Activité", + "other": "Activités" + }, + "back": "Retour", + "favorite": "Favori", + "year": { + "one": "An", + "other": "Ans" + }, + "month": { + "one": "Mois", + "other": "Mois" + }, + "day": { + "one": "Jour", + "other": "Jours" + }, + "hour": { + "one": "Heure", + "other": "Heures" + }, + "minute": { + "one": "Minute", + "other": "Minutes" + }, + "checking_database": "Vérification de la base de données...", + "syncing_search_index": "Synchronisation de l'index de recherche...", + "updating_search_index": "Mise à jour de l'index de recherche...", + "search_index_updated": "Index de recherche mis à jour", + "download_manager": "Gestionnaire de téléchargements", + "search_files": "Rechercher des fichiers", + "select_language": "Sélectionner la langue", + "all_languages": "Toutes les langues", + "all": "Tous", + "images": "Images", + "files_found": "fichiers trouvés", + "no_files_found": "Aucun fichier téléchargé trouvé", + "no_pdfs_found": "Aucun document PDF trouvé", + "confirm_delete": "Confirmer la suppression", + "delete_file_confirmation": "Êtes-vous sûr de vouloir supprimer le fichier {0}?", + "delete": "Supprimer" +} diff --git a/assets/lang/pt.json b/assets/lang/pt.json new file mode 100644 index 0000000..95d7f49 --- /dev/null +++ b/assets/lang/pt.json @@ -0,0 +1,104 @@ +{ + "title": "Estudos\nBíblicos", + "version": "Versão", + "downloading_data": "Descarregando dados...", + "updating_data": "Atualizando base de dados...", + "success_updating_data": "Dados atualizados com sucesso", + "welcome": "Início", + "home": "Estudos Bíblicos", + "search": "Pesquisa", + "searching": "Pesquisando", + "searching_in_progress": "Pesquisando...", + "loading_results": "Carregando resultados...", + "loading_more": "Carregando mais...", + "try_different_search": "Tente uma pesquisa diferente", + "search_placeholder": "Pesquisar...", + "image_saved": "Imagem salva", + "image_saved_desc": "Por favor, verifique sua galeria", + "error_saving_image": "Erro ao salvar a imagem", + "error_saving_image_desc": "Por favor, tente novamente mais tarde", + "results": { + "one": "Resultado", + "other": "Resultados" + }, + "results_found": { + "one": "Resultado encontrado", + "other": "Resultados encontrados" + }, + "calendar": "Por data", + "empty_search_term": "Por favor, introduza un termo de pesquisa", + "empty_results": "Não foram encontrados resultados", + "library": "Biblioteca", + "config": "Configuração", + "downloaded": "Baixados", + "available": "Disponíveis", + "about": "Sobre", + "settings": "Ajustes", + "lang": "Idioma", + "theme": "Tema", + "system": "Sistema", + "light": "Claro", + "dark": "Escuro", + "auto": "Automático", + "hide_thumbnails": "Ocultar miniaturas", + "download_pdf": "Baixar PDF", + "pdf_download": "Baixar automáticamente PDF", + "pdf_download_desc": "Ao acessar um PDF não disponivel, sera baixado automáticamente", + "hd_thumbnails_desc": "Somente serão baixados ao entrar em um estudo específico", + "hd_thumbnails": "Miniaturas em alta qualidade", + "locale": "Idioma", + "confirm_locale_change": "Conferir mudança de idioma", + "confirm_locale_change_desc": "Está seguro que deseja mudar o idioma?", + "no": "Não", + "yes": "Sim", + "cancel": "Cancelar", + "accept": "Aceitar", + "last_activities": "Últimas atividades", + "recent_text": "Textos recentes", + "draft": "Rascunho", + "no_text": "Nenhuma transcrição disponível", + "from": "de", + "full_screen": "Tela cheia", + "activity": { + "one": "Atividade", + "other": "Atividades" + }, + "back": "Voltar", + "favorite": "Favorito", + "year": { + "one": "Ano", + "other": "Anos" + }, + "month": { + "one": "Mes", + "other": "Meses" + }, + "day": { + "one": "Dia", + "other": "Dias" + }, + "hour": { + "one": "Hora", + "other": "Horas" + }, + "minute": { + "one": "Minuto", + "other": "Minutos" + }, + "checking_database": "Verificando base de dados...", + "syncing_search_index": "Sincronizando índice de pesquisa...", + "updating_search_index": "Atualizando índice de pesquisa...", + "search_index_updated": "Índice de pesquisa atualizado", + "download_manager": "Gerenciador de downloads", + "search_files": "Pesquisar arquivos", + "select_language": "Selecionar idioma", + "all_languages": "Todos os idiomas", + "all": "Todos", + "images": "Imagens", + "files_found": "arquivos encontrados", + "no_files_found": "Nenhum arquivo baixado encontrado", + "no_pdfs_found": "Nenhum documento PDF encontrado", + "confirm_delete": "Confirmar exclusão", + "delete_file_confirmation": "Tem certeza que deseja excluir o arquivo {0}?", + "delete": "Excluir" +} \ No newline at end of file diff --git a/assets/lang/rw.json b/assets/lang/rw.json new file mode 100644 index 0000000..a77c2bf --- /dev/null +++ b/assets/lang/rw.json @@ -0,0 +1,105 @@ +{ + "title": "Amasomo\nYa Bibiliya", + "version": "Icyiciro", + "downloading_data": "Kurimo gukuramo amakuru...", + "updating_data": "Gukurura amakuru mashya...", + "success_updating_data": "Amakuru yakuru kugirango", + "welcome": "Ahabanza", + "home": "Amasomo ya Bibiliya", + "search": "Shakisha", + "searching": "Kurimo gushakisha", + "searching_in_progress": "Kurimo gushakisha...", + "loading_results": "Kurimo gutaha ibisubizo...", + "loading_more": "Kurimo gutaha ibindi...", + "try_different_search": "Gerageza gushakisha mu bundi buryo", + "search_placeholder": "Shakisha...", + "image_saved": "Imaji yeguruka", + "image_saved_desc": "Tafadhali, angalia kwenye galeri", + "error_saving_image": "Hitilafu ili kushuka imaji", + "error_saving_image_desc": "Tafadhali, jaribu tena baadaye", + "results": { + "one": "Ibisubizo", + "other": "Ibisubizo" + }, + "results_found": { + "one": "Ibisubizo byabonetse", + "other": "Ibisubizo byabonetse" + }, + "calendar": "Ku itariki", + "empty_search_term": "Nyamuneka, shyiramo ijambo ryo gushakisha", + "empty_results": "Nta bisubizo byabonetse", + "library": "Isomero", + "config": "Amashusho", + "downloaded": "Kuramo", + "available": "Igihe", + "about": "Ibyerekeye", + "settings": "Amashusho", + "lang": "Ururimi", + "theme": "Insanganyamatsiko", + "system": "Sisitemu", + "light": "Urumuri", + "dark": "Umwijima", + "auto": "Byikora", + "hide_thumbnails": "Guhisha uturemereke", + "download_pdf": "Kuramo PDF", + "pdf_download": "Kuramo PDF byikora", + "pdf_download_desc": "Iyo ugeze kuri PDF itaboneka, irahita ikurwamo byikora", + "hd_thumbnails_desc": "Bizakururwa gusa igihe winjiye mu isomo ryihariye", + "hd_thumbnails": "Uturere two mu bwiza bwo hejuru", + "locale": "Ururimi", + "confirm_locale_change": "Emeza guhindura ururimi", + "confirm_locale_change_desc": "Urashaka guhindura ururimi koko?", + "no": "Oya", + "yes": "Yego", + "cancel": "Gusubika", + "accept": "Emera", + "last_activities": "Ibikorwa biheruka", + "recent_text": "Inyandiko ziheruka", + "draft": "Ibyo nkunda", + "no_text": "Ibyo nkunda kuri kugirango", + "from": "ku", + "full_screen": "Ibikorwa byose", + "activity": { + "one": "Igikorwa", + "other": "Ibikorwa" + }, + "back": "Subira inyuma", + "favorite": "Ibyo nkunda", + "year": { + "one": "Umwaka", + "other": "Imyaka" + }, + "month": { + "one": "Ukwezi", + "other": "Amezi" + }, + "day": { + "one": "Umunsi", + "other": "Iminsi" + }, + "hour": { + "one": "Isaha", + "other": "Amasaha" + }, + "minute": { + "one": "Umunota", + "other": "Iminota" + }, + "checking_database": "Kurimo kugenzura ububiko bw'amakuru...", + "syncing_search_index": "Kurimo guhuza ibipimo byo gushakisha...", + "updating_search_index": "Kurimo kuvugurura ibipimo byo gushakisha...", + "search_index_updated": "Ibipimo byo gushakisha byavuguruwe", + + "download_manager": "Umucungamadosiye", + "search_files": "Shakisha dosiye", + "select_language": "Hitamo ururimi", + "all_languages": "Indimi zose", + "all": "Byose", + "images": "Amashusho", + "files_found": "dosiye zabonetse", + "no_files_found": "Nta dosiye zakuruwe zabonetse", + "no_pdfs_found": "Nta nyandiko za PDF zabonetse", + "confirm_delete": "Emeza gukuraho", + "delete_file_confirmation": "Uremeza ko ushaka gukuraho dosiye {0}?", + "delete": "Kuraho" +} diff --git a/assets/svg/logo.svg b/assets/svg/logo.svg new file mode 100644 index 0000000..fab8c54 --- /dev/null +++ b/assets/svg/logo.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/flutter_native_splash.yaml b/flutter_native_splash.yaml new file mode 100644 index 0000000..88d1182 --- /dev/null +++ b/flutter_native_splash.yaml @@ -0,0 +1,144 @@ +flutter_native_splash: + # This package generates native code to customize Flutter's default white native splash screen + # with background color and splash image. + # Customize the parameters below, and run the following command in the terminal: + # dart run flutter_native_splash:create + # To restore Flutter's default white splash screen, run the following command in the terminal: + # dart run flutter_native_splash:remove + + # IMPORTANT NOTE: These parameter do not affect the configuration of Android 12 and later, which + # handle splash screens differently that prior versions of Android. Android 12 and later must be + # configured specifically in the android_12 section below. + + # color or background_image is the only required parameter. Use color to set the background + # of your splash screen to a solid color. Use background_image to set the background of your + # splash screen to a png image. This is useful for gradients. The image will be stretch to the + # size of the app. Only one parameter can be used, color and background_image cannot both be set. + color: "#AFC289" + #background_image: "assets/background.png" + + # Optional parameters are listed below. To enable a parameter, uncomment the line by removing + # the leading # character. + + # The image parameter allows you to specify an image used in the splash screen. It must be a + # png file and should be sized for 4x pixel density. + image: assets/image/logo.png + + # The branding property allows you to specify an image used as branding in the splash screen. + # It must be a png file. It is supported for Android, iOS and the Web. For Android 12, + # see the Android 12 section below. + #branding: assets/dart.png + + # To position the branding image at the bottom of the screen you can use bottom, bottomRight, + # and bottomLeft. The default values is bottom if not specified or specified something else. + #branding_mode: bottom + + # The color_dark, background_image_dark, image_dark, branding_dark are parameters that set the background + # and image when the device is in dark mode. If they are not specified, the app will use the + # parameters from above. If the image_dark parameter is specified, color_dark or + # background_image_dark must be specified. color_dark and background_image_dark cannot both be + # set. + #color_dark: "#042a49" + #background_image_dark: "assets/dark-background.png" + #image_dark: assets/splash-invert.png + #branding_dark: assets/dart_dark.png + + # From Android 12 onwards, the splash screen is handled differently than in previous versions. + # Please visit https://developer.android.com/guide/topics/ui/splash-screen + # Following are specific parameters for Android 12+. + android_12: + # The image parameter sets the splash screen icon image. If this parameter is not specified, + # the app's launcher icon will be used instead. + # Please note that the splash screen will be clipped to a circle on the center of the screen. + # App icon with an icon background: This should be 960×960 pixels, and fit within a circle + # 640 pixels in diameter. + # App icon without an icon background: This should be 1152×1152 pixels, and fit within a circle + # 768 pixels in diameter. + #image: assets/android12splash.png + + # Splash screen background color. + color: "#AFC289" + + # App icon background color. + #icon_background_color: "#111111" + + # The branding property allows you to specify an image used as branding in the splash screen. + #branding: assets/dart.png + + # The image_dark, color_dark, icon_background_color_dark, and branding_dark set values that + # apply when the device is in dark mode. If they are not specified, the app will use the + # parameters from above. + #image_dark: assets/android12splash-invert.png + #color_dark: "#042a49" + #icon_background_color_dark: "#eeeeee" + + # The android, ios and web parameters can be used to disable generating a splash screen on a given + # platform. + #android: false + #ios: false + #web: false + + # Platform specific images can be specified with the following parameters, which will override + # the respective parameter. You may specify all, selected, or none of these parameters: + #color_android: "#42a5f5" + #color_dark_android: "#042a49" + #color_ios: "#42a5f5" + #color_dark_ios: "#042a49" + #color_web: "#42a5f5" + #color_dark_web: "#042a49" + #image_android: assets/splash-android.png + #image_dark_android: assets/splash-invert-android.png + #image_ios: assets/splash-ios.png + #image_dark_ios: assets/splash-invert-ios.png + #image_web: assets/splash-web.gif + #image_dark_web: assets/splash-invert-web.gif + #background_image_android: "assets/background-android.png" + #background_image_dark_android: "assets/dark-background-android.png" + #background_image_ios: "assets/background-ios.png" + #background_image_dark_ios: "assets/dark-background-ios.png" + #background_image_web: "assets/background-web.png" + #background_image_dark_web: "assets/dark-background-web.png" + #branding_android: assets/brand-android.png + #branding_dark_android: assets/dart_dark-android.png + #branding_ios: assets/brand-ios.png + #branding_dark_ios: assets/dart_dark-ios.png + #branding_web: assets/brand-web.gif + #branding_dark_web: assets/dart_dark-web.gif + + # The position of the splash image can be set with android_gravity, ios_content_mode, and + # web_image_mode parameters. All default to center. + # + # android_gravity can be one of the following Android Gravity (see + # https://developer.android.com/reference/android/view/Gravity): bottom, center, + # center_horizontal, center_vertical, clip_horizontal, clip_vertical, end, fill, fill_horizontal, + # fill_vertical, left, right, start, or top. + #android_gravity: center + # + # ios_content_mode can be one of the following iOS UIView.ContentMode (see + # https://developer.apple.com/documentation/uikit/uiview/contentmode): scaleToFill, + # scaleAspectFit, scaleAspectFill, center, top, bottom, left, right, topLeft, topRight, + # bottomLeft, or bottomRight. + #ios_content_mode: center + # + # web_image_mode can be one of the following modes: center, contain, stretch, and cover. + #web_image_mode: center + + # The screen orientation can be set in Android with the android_screen_orientation parameter. + # Valid parameters can be found here: + # https://developer.android.com/guide/topics/manifest/activity-element#screen + #android_screen_orientation: sensorLandscape + + # To hide the notification bar, use the fullscreen parameter. Has no effect in web since web + # has no notification bar. Defaults to false. + # NOTE: Unlike Android, iOS will not automatically show the notification bar when the app loads. + # To show the notification bar, add the following code to your Flutter app: + # WidgetsFlutterBinding.ensureInitialized(); + # SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: [SystemUiOverlay.bottom, SystemUiOverlay.top], ); + #fullscreen: true + + # If you have changed the name(s) of your info.plist file(s), you can specify the filename(s) + # with the info_plist_files parameter. Remove only the # characters in the three lines below, + # do not remove any spaces: + #info_plist_files: + # - 'ios/Runner/Info-Debug.plist' + # - 'ios/Runner/Info-Release.plist' \ No newline at end of file diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..42055d9 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,98 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '12.0' # Versión mínima para la aplicación principal + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +# Agregar el target de la extensión solo si existe +extension_path = Dir.glob("**/*SearchWidgetExtension.xcodeproj").first || Dir.glob("**/SearchWidgetExtension/SearchWidgetExtension.xcodeproj").first +if extension_path != nil + puts "Encontrada la extensión SearchWidgetExtension, configurando pods..." + target 'SearchWidgetExtension' do + use_frameworks! + use_modular_headers! + # Remove SwiftUI pod as it's part of the iOS SDK + # pod 'SwiftUI' # Framework básico para SwiftUI + + # Don't share the same pods as the main app to avoid circular dependencies + # Add only specific pods needed by the extension + end +else + puts "No se encontró la extensión SearchWidgetExtension, omitiendo configuración específica." +end + +post_install do |installer| + # Configurar iOS 12.0 como el mínimo por defecto, pero dejar que cada pod use su propio mínimo + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + # Asegurarse de que ningún pod tenga deployment target menor que 12.0 + if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 12.0 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' + end + end + end + + # Configurar los targets de Flutter + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + + target.build_configurations.each do |config| + # Configuraciones específicas para cada target + if target.name == 'Runner' + # Configuraciones para el target principal + config.build_settings['EXPANDED_CODE_SIGN_IDENTITY'] = "" + config.build_settings['CODE_SIGNING_REQUIRED'] = "NO" + config.build_settings['CODE_SIGNING_ALLOWED'] = "NO" + end + + # Configuraciones para la extensión si existe + if target.name.include?('SearchWidgetExtension') + config.build_settings['EXPANDED_CODE_SIGN_IDENTITY'] = "" + config.build_settings['CODE_SIGNING_REQUIRED'] = "NO" + config.build_settings['CODE_SIGNING_ALLOWED'] = "NO" + config.build_settings['SKIP_INSTALL'] = 'YES' + # Establecer iOS 16.1 como mínimo solo para la extensión (requisito para Live Activities) + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.1' + end + end + end + + # Corregir problemas con el toolchain + installer.generated_projects.each do |project| + project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['DEVELOPMENT_TEAM'] = '' + end + end + end +end \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..0f43270 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,164 @@ +PODS: + - audio_session (0.0.1): + - Flutter + - country_codes (0.0.1): + - Flutter + - device_info_plus (0.0.1): + - Flutter + - Flutter (1.0.0) + - flutter_app_group_directory (0.0.1): + - Flutter + - flutter_local_notifications (0.0.1): + - Flutter + - flutter_mimir (0.0.1) + - flutter_native_splash (2.4.3): + - Flutter + - gal (1.0.0): + - Flutter + - FlutterMacOS + - just_audio (0.0.1): + - Flutter + - FlutterMacOS + - live_activities (0.0.1): + - Flutter + - package_info_plus (0.4.5): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - sqlite3 (3.49.1): + - sqlite3/common (= 3.49.1) + - sqlite3/common (3.49.1) + - sqlite3/dbstatvtab (3.49.1): + - sqlite3/common + - sqlite3/fts5 (3.49.1): + - sqlite3/common + - sqlite3/perf-threadsafe (3.49.1): + - sqlite3/common + - sqlite3/rtree (3.49.1): + - sqlite3/common + - sqlite3_flutter_libs (0.0.1): + - Flutter + - FlutterMacOS + - sqlite3 (~> 3.49.1) + - sqlite3/dbstatvtab + - sqlite3/fts5 + - sqlite3/perf-threadsafe + - sqlite3/rtree + - syncfusion_flutter_pdfviewer (0.0.1): + - Flutter + - url_launcher_ios (0.0.1): + - Flutter + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS + - wakelock_plus (0.0.1): + - Flutter + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - audio_session (from `.symlinks/plugins/audio_session/ios`) + - country_codes (from `.symlinks/plugins/country_codes/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - Flutter (from `Flutter`) + - flutter_app_group_directory (from `.symlinks/plugins/flutter_app_group_directory/ios`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - flutter_mimir (from `.symlinks/plugins/flutter_mimir/ios`) + - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) + - gal (from `.symlinks/plugins/gal/darwin`) + - just_audio (from `.symlinks/plugins/just_audio/darwin`) + - live_activities (from `.symlinks/plugins/live_activities/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) + - syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) + - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) + +SPEC REPOS: + trunk: + - sqlite3 + +EXTERNAL SOURCES: + audio_session: + :path: ".symlinks/plugins/audio_session/ios" + country_codes: + :path: ".symlinks/plugins/country_codes/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + Flutter: + :path: Flutter + flutter_app_group_directory: + :path: ".symlinks/plugins/flutter_app_group_directory/ios" + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" + flutter_mimir: + :path: ".symlinks/plugins/flutter_mimir/ios" + flutter_native_splash: + :path: ".symlinks/plugins/flutter_native_splash/ios" + gal: + :path: ".symlinks/plugins/gal/darwin" + just_audio: + :path: ".symlinks/plugins/just_audio/darwin" + live_activities: + :path: ".symlinks/plugins/live_activities/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + sqlite3_flutter_libs: + :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" + syncfusion_flutter_pdfviewer: + :path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + video_player_avfoundation: + :path: ".symlinks/plugins/video_player_avfoundation/darwin" + wakelock_plus: + :path: ".symlinks/plugins/wakelock_plus/ios" + webview_flutter_wkwebview: + :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" + +SPEC CHECKSUMS: + audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 + country_codes: b534fe92b5dd4d4cfd31a720f2bfa223373d162c + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + flutter_app_group_directory: 55b5362007d1c0cb45dc1dd1e94f67d615f45a6b + flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 + flutter_mimir: abc5575f7deea72a2716fb3a75295984681dfd87 + flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf + gal: baecd024ebfd13c441269ca7404792a7152fde89 + just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed + live_activities: f2e133059358f99655c8d181d65ff54f024a6e93 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 + sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241 + syncfusion_flutter_pdfviewer: dfb514751af5b6b71e504c9c04a2e4ddbc1dd895 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b + wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49 + webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 + +PODFILE CHECKSUM: c6b83d8cd699251ccc3bbb754fede6c1135cc1f5 + +COCOAPODS: 1.16.2 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/100.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 0000000..08c3059 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/100.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/1024.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000..ebcedef Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/114.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 0000000..457f39d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/120.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 0000000..6f6897f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/128.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/128.png new file mode 100644 index 0000000..8bb5efb Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/128.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/144.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/144.png new file mode 100644 index 0000000..0a4519f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/144.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/152.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 0000000..898535c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/152.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/16.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/16.png new file mode 100644 index 0000000..5f3753f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/16.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/167.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 0000000..5d6ff05 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/167.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/180.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 0000000..a55bc4e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/20.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 0000000..d06312b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/20.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/256.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/256.png new file mode 100644 index 0000000..699f3d2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/256.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/29.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 0000000..6cf2f54 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/32.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/32.png new file mode 100644 index 0000000..b9912cf Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/32.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/40.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 0000000..64665ec Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/50.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/50.png new file mode 100644 index 0000000..05da52b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/50.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/512.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/512.png new file mode 100644 index 0000000..9b90cb6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/512.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/57.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 0000000..ace1ec2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/58.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 0000000..e7efc4f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/60.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 0000000..007d733 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/64.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/64.png new file mode 100644 index 0000000..f561a85 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/64.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/72.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/72.png new file mode 100644 index 0000000..63bf7bb Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/72.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/76.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 0000000..144a28d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/76.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/80.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 0000000..8bd503b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/87.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 0000000..77ce450 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..7d60b3a --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.lgcc.search + + + diff --git a/ios/SearchWidget/AppIntent.swift b/ios/SearchWidget/AppIntent.swift new file mode 100644 index 0000000..06a68cb --- /dev/null +++ b/ios/SearchWidget/AppIntent.swift @@ -0,0 +1,18 @@ +// +// AppIntent.swift +// SearchWidget +// +// Created by Johann Villegas on 9/04/25. +// + +import WidgetKit +import AppIntents + +struct ConfigurationAppIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource { "Configuration" } + static var description: IntentDescription { "This is an example widget." } + + // An example configurable parameter. + @Parameter(title: "Favorite Emoji", default: "😃") + var favoriteEmoji: String +} diff --git a/ios/SearchWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/SearchWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios/SearchWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/SearchWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/SearchWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/ios/SearchWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/SearchWidget/Assets.xcassets/Contents.json b/ios/SearchWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/SearchWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/SearchWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/ios/SearchWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios/SearchWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/SearchWidget/Info.plist b/ios/SearchWidget/Info.plist new file mode 100644 index 0000000..04f07b5 --- /dev/null +++ b/ios/SearchWidget/Info.plist @@ -0,0 +1,13 @@ + + + + + NSSupportsLiveActivities + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/SearchWidget/SearchWidget.swift b/ios/SearchWidget/SearchWidget.swift new file mode 100644 index 0000000..75b0068 --- /dev/null +++ b/ios/SearchWidget/SearchWidget.swift @@ -0,0 +1,226 @@ +// +// SearchWidget.swift +// SearchWidget +// +// Created by Johann Villegas on 9/04/25. +// + +import WidgetKit +import SwiftUI +import ActivityKit + +// Shared FlutterLiveActivities definition for use in both app and widget extension +struct FlutterLiveActivities: ActivityAttributes, Identifiable { + public typealias ContentState = FlutterLiveActivitiesContent + + public struct FlutterLiveActivitiesContent: Codable, Hashable { + var title: String + var query: String + var resultsCount: String + var timestamp: String + } + + var id = UUID() +} + +// Define the lock screen/banner state of Live Activities +@available(iOS 16.2, *) +struct FlutterLiveActivitiesLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: FlutterLiveActivities.self) { context in + // Lock screen/banner UI goes here + LockScreenLiveActivityView(context: context) + } dynamicIsland: { context in + // Dynamic Island UI goes here + DynamicIslandLiveActivityView(context: context) + } + } +} + +@available(iOS 16.2, *) +struct LockScreenLiveActivityView: View { + let context: ActivityViewContext + + var body: some View { + VStack { + Text(context.state.title) + .font(.headline) + + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.green) + Text(context.state.query) + .font(.subheadline) + } + + HStack { + Image(systemName: "doc.text") + .foregroundColor(.blue) + Text("Results: \(context.state.resultsCount)") + .font(.subheadline) + } + + Text("Updated: \(formattedDate(from: context.state.timestamp))") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + } + + func formattedDate(from isoString: String) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + if let date = dateFormatter.date(from: isoString) { + dateFormatter.dateFormat = "HH:mm:ss" + return dateFormatter.string(from: date) + } + return isoString + } +} + +@available(iOS 16.2, *) +struct DynamicIslandLiveActivityView: View { + let context: ActivityViewContext + + var body: some View { + DynamicIsland { + // Expanded state + DynamicIslandExpandedRegion(.leading) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.green) + Text(context.state.query) + .font(.headline) + .lineLimit(1) + } + } + + DynamicIslandExpandedRegion(.trailing) { + HStack { + Image(systemName: "doc.text") + .foregroundColor(.blue) + Text(context.state.resultsCount) + .font(.headline) + } + } + + DynamicIslandExpandedRegion(.bottom) { + VStack(alignment: .leading) { + Text(context.state.title) + .font(.caption) + Text("Updated: \(formattedDate(from: context.state.timestamp))") + .font(.caption) + .foregroundColor(.secondary) + } + } + } compactLeading: { + Image(systemName: "magnifyingglass") + .foregroundColor(.green) + } compactTrailing: { + Text(context.state.resultsCount) + .font(.headline) + } minimal: { + Image(systemName: "magnifyingglass") + .foregroundColor(.green) + } + } + + func formattedDate(from isoString: String) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + if let date = dateFormatter.date(from: isoString) { + dateFormatter.dateFormat = "HH:mm:ss" + return dateFormatter.string(from: date) + } + return isoString + } +} + +struct SearchWidget: Widget { + let kind: String = "SearchWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: EmptyTimelineProvider()) { _ in + EmptyWidgetView() + } + .configurationDisplayName("Estudios Bíblicos") + .description("Search Widget for Bible Studies App") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +// Empty widget for devices that don't support Live Activities +struct EmptyWidgetView: View { + var body: some View { + ZStack { + Color(red: 0.42, green: 0.55, blue: 0.14) // #6b8e23 color + VStack { + Image(systemName: "magnifyingglass") + .resizable() + .scaledToFit() + .frame(width: 40, height: 40) + .foregroundColor(.white) + Text("Estudios Bíblicos") + .font(.headline) + .foregroundColor(.white) + } + .padding() + } + } +} + +// Empty provider for devices that don't support Live Activities +struct EmptyTimelineProvider: TimelineProvider { + func placeholder(in context: Context) -> EmptyTimelineEntry { + EmptyTimelineEntry() + } + + func getSnapshot(in context: Context, completion: @escaping (EmptyTimelineEntry) -> ()) { + let entry = EmptyTimelineEntry() + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + let entry = EmptyTimelineEntry() + let timeline = Timeline(entries: [entry], policy: .never) + completion(timeline) + } +} + +struct EmptyTimelineEntry: TimelineEntry { + let date: Date = Date() +} + +struct SearchWidget_Previews: PreviewProvider { + static var previews: some View { + SearchWidget() + .previewContext(WidgetPreviewContext(family: .systemSmall)) + } +} + +// Preview for Live Activities +@available(iOS 16.2, *) +struct FlutterLiveActivitiesLiveActivity_Previews: PreviewProvider { + static let attributes = FlutterLiveActivities() + static let contentState = FlutterLiveActivities.ContentState( + title: "Searching Bible Studies", + query: "resurrection", + resultsCount: "42", + timestamp: ISO8601DateFormatter().string(from: Date()) + ) + + static var previews: some View { + attributes + .previewContext(contentState, viewKind: .dynamicIsland(.compact)) + .previewDisplayName("Island Compact") + attributes + .previewContext(contentState, viewKind: .dynamicIsland(.expanded)) + .previewDisplayName("Island Expanded") + attributes + .previewContext(contentState, viewKind: .dynamicIsland(.minimal)) + .previewDisplayName("Minimal") + attributes + .previewContext(contentState, viewKind: .content) + .previewDisplayName("Notification") + } +} diff --git a/ios/SearchWidget/SearchWidgetBundle.swift b/ios/SearchWidget/SearchWidgetBundle.swift new file mode 100644 index 0000000..c3ff080 --- /dev/null +++ b/ios/SearchWidget/SearchWidgetBundle.swift @@ -0,0 +1,19 @@ +// +// SearchWidgetBundle.swift +// SearchWidget +// +// Created by Johann Villegas on 9/04/25. +// + +import WidgetKit +import SwiftUI + +@main +struct SearchWidgetBundle: WidgetBundle { + var body: some Widget { + SearchWidget() + if #available(iOS 16.2, *) { + FlutterLiveActivitiesLiveActivity() + } + } +} diff --git a/ios/SearchWidget/SearchWidgetControl.swift b/ios/SearchWidget/SearchWidgetControl.swift new file mode 100644 index 0000000..d5e26d5 --- /dev/null +++ b/ios/SearchWidget/SearchWidgetControl.swift @@ -0,0 +1,77 @@ +// +// SearchWidgetControl.swift +// SearchWidget +// +// Created by Johann Villegas on 9/04/25. +// + +import AppIntents +import SwiftUI +import WidgetKit + +struct SearchWidgetControl: ControlWidget { + static let kind: String = "com.carpa.searchEngine.SearchWidget" + + var body: some ControlWidgetConfiguration { + AppIntentControlConfiguration( + kind: Self.kind, + provider: Provider() + ) { value in + ControlWidgetToggle( + "Start Timer", + isOn: value.isRunning, + action: StartTimerIntent(value.name) + ) { isRunning in + Label(isRunning ? "On" : "Off", systemImage: "timer") + } + } + .displayName("Timer") + .description("A an example control that runs a timer.") + } +} + +extension SearchWidgetControl { + struct Value { + var isRunning: Bool + var name: String + } + + struct Provider: AppIntentControlValueProvider { + func previewValue(configuration: TimerConfiguration) -> Value { + SearchWidgetControl.Value(isRunning: false, name: configuration.timerName) + } + + func currentValue(configuration: TimerConfiguration) async throws -> Value { + let isRunning = true // Check if the timer is running + return SearchWidgetControl.Value(isRunning: isRunning, name: configuration.timerName) + } + } +} + +struct TimerConfiguration: ControlConfigurationIntent { + static let title: LocalizedStringResource = "Timer Name Configuration" + + @Parameter(title: "Timer Name", default: "Timer") + var timerName: String +} + +struct StartTimerIntent: SetValueIntent { + static let title: LocalizedStringResource = "Start a timer" + + @Parameter(title: "Timer Name") + var name: String + + @Parameter(title: "Timer is running") + var value: Bool + + init() {} + + init(_ name: String) { + self.name = name + } + + func perform() async throws -> some IntentResult { + // Start the timer… + return .result() + } +} diff --git a/ios/SearchWidget/SearchWidgetLiveActivity.swift b/ios/SearchWidget/SearchWidgetLiveActivity.swift new file mode 100644 index 0000000..210a66a --- /dev/null +++ b/ios/SearchWidget/SearchWidgetLiveActivity.swift @@ -0,0 +1,80 @@ +// +// SearchWidgetLiveActivity.swift +// SearchWidget +// +// Created by Johann Villegas on 9/04/25. +// + +import ActivityKit +import WidgetKit +import SwiftUI + +struct SearchWidgetAttributes: ActivityAttributes { + public typealias LiveDeliveryData = ContentState + + public struct ContentState: Codable, Hashable { + var title: String + var body: String + var searchTerm: String + var totalResults: Int + var timestamp: Int64 + } + + var id = UUID() +} + +struct SearchWidgetLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: SearchWidgetAttributes.self) { context in + // Lock Screen/Banner UI + VStack { + HStack { + Text(context.state.title) + .font(.headline) + Spacer() + Text("\(context.state.totalResults)") + .font(.caption) + } + Text(context.state.body) + .font(.subheadline) + if !context.state.searchTerm.isEmpty { + Text("Search: \(context.state.searchTerm)") + .font(.caption) + } + } + .padding() + } dynamicIsland: { context in + // Dynamic Island UI + DynamicIsland { + // Expanded UI + DynamicIslandExpandedRegion(.leading) { + Text(context.state.title) + .font(.headline) + } + + DynamicIslandExpandedRegion(.trailing) { + Text("\(context.state.totalResults)") + .font(.caption) + } + + DynamicIslandExpandedRegion(.bottom) { + Text(context.state.body) + .font(.subheadline) + if !context.state.searchTerm.isEmpty { + Text("Search: \(context.state.searchTerm)") + .font(.caption) + } + } + } compactLeading: { + // Compact Leading + Text("\(context.state.totalResults)") + } compactTrailing: { + // Compact Trailing + Image(systemName: "magnifyingglass") + } minimal: { + // Minimal UI + Image(systemName: "magnifyingglass") + } + } + } +} diff --git a/ios/SearchWidgetExtension.entitlements b/ios/SearchWidgetExtension.entitlements new file mode 100644 index 0000000..6d193a1 --- /dev/null +++ b/ios/SearchWidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.carpa.searchEngine.widget + + + diff --git a/lib/controllers/notification_controller.dart b/lib/controllers/notification_controller.dart new file mode 100644 index 0000000..c9338e8 --- /dev/null +++ b/lib/controllers/notification_controller.dart @@ -0,0 +1,85 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter/material.dart'; + +class NotificationController { + static final FlutterLocalNotificationsPlugin _notifications = + FlutterLocalNotificationsPlugin(); + + static Future initialize() async { + const androidSettings = + AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosSettings = DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + ); + + const initSettings = InitializationSettings( + android: androidSettings, + iOS: iosSettings, + ); + + await _notifications.initialize( + initSettings, + onDidReceiveNotificationResponse: (NotificationResponse response) { + if (response.payload != null) { + // Handle notification tap + // You can navigate or perform actions based on the payload + } + }, + ); + } + + static Future showNotification({ + required String title, + required String body, + String? payload, + }) async { + const androidDetails = AndroidNotificationDetails( + 'basic_channel', + 'Basic notifications', + channelDescription: 'Notification channel for basic tests', + importance: Importance.high, + priority: Priority.high, + color: Color(0xFF9D50DD), + enableLights: true, + enableVibration: true, + playSound: true, + ); + + const iosDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ); + + const details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _notifications.show( + 0, + title, + body, + details, + payload: payload, + ); + } + + static Future requestPermissions() async { + await _notifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.requestNotificationsPermission(); + + await _notifications + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>() + ?.requestPermissions( + alert: true, + badge: true, + sound: true, + ); + } +} diff --git a/lib/database.dart b/lib/database.dart new file mode 100644 index 0000000..0870144 --- /dev/null +++ b/lib/database.dart @@ -0,0 +1,826 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:drift/drift.dart' as drift; +import 'package:search_engine/services/config_service.dart'; +import 'package:search_engine/services/mimir_service.dart'; + +part 'database.g.dart'; + +class Draft { + final String id; + final String title; + final DateTime date; + final int activity; + final String thumbnail; + final int draft; + final String locale; + final String country; + final String city; + final String? body; + final String? pdf; + final String languagesCode; + final Map? searchResultData; + final int position; + final int length; + + Draft({ + required this.id, + required this.title, + required this.date, + required this.activity, + required this.thumbnail, + required this.draft, + required this.locale, + required this.country, + required this.city, + this.body, + this.pdf, + required this.languagesCode, + this.searchResultData, + this.position = 0, + this.length = 0, + }); +} + +class Messages extends Table { + TextColumn get id => text()(); + + TextColumn get country => text()(); + + TextColumn get city => text()(); + + DateTimeColumn get date => dateTime()(); + + IntColumn get activity => integer()(); + + IntColumn get draft => integer()(); + + TextColumn get thumbnail => text()(); + + @override + Set get primaryKey => {id}; +} + +@TableIndex(name: 'title_index', columns: {#title}) +@TableIndex(name: 'body_index', columns: {#body}) +@TableIndex(name: 'message_id_index', columns: {#messageId}) +class Translations extends Table { + TextColumn get messageId => text()(); + + TextColumn get title => text()(); + + TextColumn get body => text()(); + + TextColumn get languagesCode => text()(); + + TextColumn get pdf => text().nullable()(); + + @override + Set get primaryKey => {messageId, languagesCode}; +} + +class Favorites extends Table { + TextColumn get id => text()(); + + @override + Set get primaryKey => {id}; +} + +@DriftDatabase(tables: [Messages, Translations, Favorites]) +class AppDatabase extends _$AppDatabase { + AppDatabase() : super(_createConnection()); + + static DatabaseConnection _createConnection() { + return DatabaseConnection.delayed(Future(() async { + final dbFolder = await getApplicationDocumentsDirectory(); + final filePath = p.join(dbFolder.path, 'LGCC_Search/internal/db.sqlite'); + + final file = File(filePath); + final database = NativeDatabase(file, + logStatements: false, cachePreparedStatements: true); + + return DatabaseConnection(database); + })); + } + + @override + int get schemaVersion => 1; + + Future addMessages(List drafts) async { + if (drafts.isEmpty) { + return; // No hay nada que insertar + } + + try { + // Preparar las listas para mensajes y traducciones + List messagesList = []; + List translationsList = []; + + // Validar y preparar los datos + for (final draft in drafts) { + // Validar que el ID no esté vacío + if (draft.id.isEmpty) { + if (kDebugMode) { + print('Saltando mensaje con ID vacío'); + } + continue; + } + + // Preparar el mensaje + messagesList.add(MessagesCompanion( + id: Value(draft.id), + country: Value(draft.country), + city: Value(draft.city), + date: Value(draft.date), + activity: Value(draft.activity), + thumbnail: Value(draft.thumbnail), + draft: Value(draft.draft), + )); + + // Preparar la traducción + translationsList.add(TranslationsCompanion( + messageId: Value(draft.id), + body: Value(draft.body ?? ''), + languagesCode: Value(draft.languagesCode), + title: Value(draft.title), + pdf: Value(draft.pdf ?? ''), + )); + } + + // Ejecutar las inserciones en una transacción para garantizar la atomicidad + await transaction(() async { + // Insertar mensajes con manejo de conflictos + for (var message in messagesList) { + await into(messages).insert( + message, + onConflict: DoUpdate( + (old) => MessagesCompanion( + country: message.country, + city: message.city, + date: message.date, + activity: message.activity, + thumbnail: message.thumbnail, + draft: message.draft, + ), + target: [messages.id], + ), + ); + } + + // Insertar traducciones con manejo de conflictos + for (var translation in translationsList) { + await into(translations).insert( + translation, + onConflict: DoUpdate( + (old) => TranslationsCompanion( + title: translation.title, + body: translation.body, + pdf: translation.pdf, + ), + target: [translations.messageId, translations.languagesCode], + ), + ); + } + }); + } catch (e) { + if (kDebugMode) { + print('Error al insertar mensajes en la base de datos: $e'); + } + // Reintento con enfoque más seguro en caso de error + try { + // Insertar uno por uno para identificar registros problemáticos + for (int i = 0; i < drafts.length; i++) { + final draft = drafts[i]; + try { + // Insertar mensaje + await into(messages).insert( + MessagesCompanion( + id: Value(draft.id), + country: Value(draft.country), + city: Value(draft.city), + date: Value(draft.date), + activity: Value(draft.activity), + thumbnail: Value(draft.thumbnail), + draft: Value(draft.draft), + ), + onConflict: DoUpdate( + (old) => MessagesCompanion( + country: Value(draft.country), + city: Value(draft.city), + date: Value(draft.date), + activity: Value(draft.activity), + thumbnail: Value(draft.thumbnail), + draft: Value(draft.draft), + ), + target: [messages.id], + ), + ); + + // Insertar traducción + await into(translations).insert( + TranslationsCompanion( + messageId: Value(draft.id), + body: Value(draft.body ?? ''), + languagesCode: Value(draft.languagesCode), + title: Value(draft.title), + pdf: Value(draft.pdf ?? ''), + ), + onConflict: DoUpdate( + (old) => TranslationsCompanion( + title: Value(draft.title), + body: Value(draft.body ?? ''), + pdf: Value(draft.pdf ?? ''), + ), + target: [translations.messageId, translations.languagesCode], + ), + ); + } catch (innerError) { + if (kDebugMode) { + print( + 'Error al insertar mensaje #$i (ID: ${draft.id}): $innerError'); + } + // Continuar con el siguiente registro + } + } + } catch (fallbackError) { + if (kDebugMode) { + print('Error en el proceso de recuperación: $fallbackError'); + } + } + } + } + + Future>> getPdfList() async { + final locale = await ConfigService.getLocale(); + try { + final queryResult = await customSelect( + "SELECT pdf, id, t.title, date, m.country, activity FROM messages LEFT JOIN translations ON messages.id = translations.message_id WHERE pdf <> '' AND locale = '$locale'", + readsFrom: {messages}).map((row) { + return { + 'pdf': row.read('pdf'), + 'title': row.read('title') + }; + }).get(); + return queryResult; + } catch (e) { + if (kDebugMode) { + print('Error fetching PDF list: $e'); + } + return []; + } + } + + Future> getMessages(bool isDraft, [String? localeParam]) async { + final locale = localeParam ?? await ConfigService.getLocale(); + if (locale.isEmpty) { + return []; + } + + try { + // Consulta SQL optimizada sin usar INDEXED BY + final queryResult = await customSelect( + """ + SELECT DISTINCT + m.id, + m.date, + m.activity, + m.thumbnail, + m.draft, + m.country, + m.city, + t.languages_code as locale, + t.title, + t.pdf, + t.languages_code + FROM + messages m + JOIN translations t ON m.id = t.message_id + WHERE + m.draft = ? + AND t.languages_code = ? + ORDER BY m.date DESC + LIMIT 20 + """, + variables: [ + Variable(isDraft ? 1 : 0), + Variable.withString(locale), + ], + readsFrom: {messages, translations}, + ).map((row) { + return Draft( + id: row.read('id'), + title: row.read('title'), + date: row.read('date'), + activity: row.read('activity'), + thumbnail: row.read('thumbnail'), + draft: row.read('draft'), + locale: row.read('locale'), + country: row.read('country'), + city: row.read('city'), + // Cargar el cuerpo solo cuando sea necesario para mejorar el rendimiento + body: '', + pdf: row.read('pdf') ?? '', + languagesCode: row.read('languages_code'), + ); + }).get(); + + return queryResult; + } catch (e) { + if (kDebugMode) { + print('Error fetching messages: $e'); + } + return []; + } + } + + // Nuevo método para cargar el cuerpo de un mensaje específico cuando sea necesario + Future getMessageBody(String messageId, String languageCode) async { + try { + final result = await customSelect( + """ + SELECT body + FROM translations + WHERE message_id = ? AND languages_code = ? + """, + variables: [ + Variable.withString(messageId), + Variable.withString(languageCode), + ], + ).getSingleOrNull(); + + return result?.read('body') ?? ''; + } catch (e) { + if (kDebugMode) { + print('Error fetching message body: $e'); + } + return ''; + } + } + + Future> getYearActivities(String year, String month) async { + final locale = await ConfigService.getLocale(); + try { + final queryResult = await customSelect( + "SELECT DISTINCT m.id, t.title, m.date, m.activity, m.thumbnail, m.draft, t.languages_code as locale, m.country, m.city, t.body, t.pdf, t.languages_code, strftime('%Y', datetime(m.date, 'unixepoch')) AS year, strftime('%m', datetime(m.date, 'unixepoch')) AS month " + "FROM messages m JOIN translations t ON m.id = t.message_id " + "WHERE strftime('%Y', datetime(m.date, 'unixepoch')) = ? AND strftime('%m', datetime(m.date, 'unixepoch')) = ? AND t.languages_code = ? " + "ORDER BY m.date DESC, m.activity DESC", + variables: [ + Variable.withString(year), + Variable.withString(month), + Variable.withString(locale) + ], + readsFrom: {messages, translations}, + ).map((row) { + final id = row.read('id'); + final title = row.read('title'); + final date = row.read('date'); + final activity = row.read('activity'); + final thumbnail = row.read('thumbnail'); + final draft = row.read('draft'); + final locale = row.read('locale'); + final country = row.read('country'); + final city = row.read('city'); + final body = row.read('body') ?? ''; + final pdf = row.read('pdf') ?? ''; + final languagesCode = row.read('languages_code'); + return Draft( + id: id, + title: title, + date: date, + activity: activity, + thumbnail: thumbnail, + draft: draft, + locale: locale, + country: country, + city: city, + body: body, + pdf: pdf, + languagesCode: languagesCode, + ); + }).get(); + + return queryResult; + } catch (e) { + if (kDebugMode) { + print('Error fetching activities: $e'); + } + return []; + } + } + + Future> getMonths(String year) async { + try { + final queryResult = await customSelect( + "SELECT DISTINCT strftime('%m', datetime(date, 'unixepoch')) AS month FROM messages WHERE strftime('%Y', datetime(date, 'unixepoch')) = ? ORDER BY month ASC", + variables: [Variable.withString(year)], + readsFrom: {messages}, + ).map((row) { + return row.read('month'); + }).get(); + + return queryResult; + } catch (e) { + if (kDebugMode) { + print('Error fetching months: $e'); + } + return []; + } + } + + Future toggleFavorite(String id) async { + final result = await customSelect( + 'SELECT COUNT(*) AS count FROM favorites WHERE id = ?', + variables: [Variable.withString(id)], + ).getSingle(); + + final isFavorite = result.read('count') > 0; + + if (isFavorite) { + delete(favorites).delete(FavoritesCompanion(id: Value(id))); + } else { + into(favorites).insert( + FavoritesCompanion(id: Value(id)), + ); + } + } + + Future checkFavorite(String id) async { + final result = await customSelect( + 'SELECT DISTINCT COUNT(*) AS count FROM favorites WHERE id = ?', + variables: [Variable.withString(id)], + ).getSingle(); + final isFavorite = result.read('count') > 0; + return isFavorite; + } + + Future> getFavorites() async { + final result = await customSelect('SELECT DISTINCT id FROM favorites') + .map((row) => row.read('id')) + .get(); + return result; + } + + // Método para obtener los años disponibles en la base de datos + Future> getAvailableYears() async { + try { + final queryResult = await customSelect( + "SELECT DISTINCT strftime('%Y', datetime(date, 'unixepoch')) AS year FROM messages ORDER BY year DESC", + readsFrom: {messages}, + ).map((row) { + return row.read('year'); + }).get(); + + return queryResult; + } catch (e) { + if (kDebugMode) { + print('Error fetching available years: $e'); + } + return []; + } + } + + // Método para obtener mensajes filtrados por año y mes + Future> getFilteredMessages(String year, String month, + [String? localeParam]) async { + final locale = localeParam ?? await ConfigService.getLocale(); + if (locale.isEmpty) { + return []; + } + + try { + final queryResult = await customSelect( + """ + SELECT DISTINCT + m.id, + m.date, + m.activity, + m.thumbnail, + m.draft, + m.country, + m.city, + t.languages_code as locale, + t.title, + t.pdf, + t.languages_code + FROM + messages m + JOIN translations t ON m.id = t.message_id + WHERE + strftime('%Y', datetime(m.date, 'unixepoch')) = ? + AND strftime('%m', datetime(m.date, 'unixepoch')) = ? + AND t.languages_code = ? + ORDER BY m.date DESC + """, + variables: [ + Variable.withString(year), + Variable.withString(month), + Variable.withString(locale), + ], + readsFrom: {messages, translations}, + ).map((row) { + return Draft( + id: row.read('id'), + title: row.read('title'), + date: row.read('date'), + activity: row.read('activity'), + thumbnail: row.read('thumbnail'), + draft: row.read('draft'), + locale: row.read('locale'), + country: row.read('country'), + city: row.read('city'), + body: '', + pdf: row.read('pdf') ?? '', + languagesCode: row.read('languages_code'), + ); + }).get(); + + return queryResult; + } catch (e) { + if (kDebugMode) { + print('Error fetching filtered messages: $e'); + } + return []; + } + } + + // Método para obtener todos los mensajes (borradores y no borradores) + Future> getAllMessages([String? localeParam, int? limit]) async { + final locale = localeParam ?? await ConfigService.getLocale(); + if (locale.isEmpty) { + return []; + } + + try { + // Consulta SQL optimizada para obtener todos los mensajes + var query = """ + SELECT DISTINCT + m.id, + m.date, + m.activity, + m.thumbnail, + m.draft, + m.country, + m.city, + t.languages_code as locale, + t.title, + t.body, + t.pdf, + t.languages_code + FROM + messages m + JOIN translations t ON m.id = t.message_id + WHERE + t.languages_code = ? + ORDER BY m.date DESC + """; + + // Añadir límite si se especifica + if (limit != null) { + query += " LIMIT $limit"; + } + + final queryResult = await customSelect( + query, + variables: [ + Variable.withString(locale), + ], + readsFrom: {messages, translations}, + ).map((row) { + return Draft( + id: row.read('id'), + title: row.read('title'), + date: row.read('date'), + activity: row.read('activity'), + thumbnail: row.read('thumbnail'), + draft: row.read('draft'), + locale: row.read('locale'), + country: row.read('country'), + city: row.read('city'), + body: row.read('body') ?? '', + pdf: row.read('pdf') ?? '', + languagesCode: row.read('languages_code'), + ); + }).get(); + + return queryResult; + } catch (e) { + if (kDebugMode) { + print('Error fetching all messages: $e'); + } + return []; + } + } + + Future> searchMessages(String query, String languagesCode) async { + try { + final mimirService = MimirService(); + await mimirService.initialize(); + + // Buscar en Mimir para obtener los IDs relevantes y metadatos + final searchResult = await mimirService.search(query, languagesCode); + + // Extraer IDs de los resultados + List> searchResultsData = searchResult['results']; + List docIds = + searchResultsData.map((doc) => doc['id'] as String).toList(); + + if (docIds.isEmpty) { + return []; + } + + // Convertir los IDs en una cadena para la consulta SQL + final String idList = docIds.map((id) => "'$id'").join(','); + + // Buscar los documentos completos en la base de datos + final queryResult = await customSelect( + ''' + SELECT DISTINCT + m.id, + m.date, + m.activity, + m.thumbnail, + m.draft, + m.country, + m.city, + t.languages_code as locale, + t.title, + t.pdf, + t.languages_code, + t.body + FROM + messages m + JOIN translations t ON m.id = t.message_id + WHERE + m.id IN ($idList) AND t.languages_code = ? + ORDER BY m.date DESC + ''', + variables: [Variable.withString(languagesCode)], + readsFrom: {messages, translations}, + ).map((row) { + final String id = row.read('id'); + + // Buscar los datos del resultado de búsqueda correspondiente + Map? resultData; + for (var data in searchResultsData) { + if (data['id'] == id) { + resultData = data; + break; + } + } + + return Draft( + id: id, + title: row.read('title'), + date: row.read('date'), + activity: row.read('activity'), + thumbnail: row.read('thumbnail'), + draft: row.read('draft'), + locale: row.read('locale'), + country: row.read('country'), + city: row.read('city'), + body: row.read('body'), + pdf: row.read('pdf') ?? '', + languagesCode: row.read('languages_code'), + searchResultData: { + ...resultData ?? {}, + 'allResultIds': docIds, + 'mimirSearchResult': searchResult, + }, + ); + }).get(); + + return queryResult; + } catch (e) { + if (kDebugMode) { + print('Error searching messages: $e'); + } + return []; + } + } + + // Método para obtener todos los PDFs disponibles + Future> getAvailablePdfs(String locale) async { + try { + final query = """ + SELECT DISTINCT + m.id, + m.date, + m.activity, + m.thumbnail, + m.draft, + m.country, + m.city, + t.languages_code as locale, + t.title, + t.pdf, + t.languages_code + FROM + messages m + JOIN translations t ON m.id = t.message_id + WHERE + t.languages_code = ? + AND t.pdf IS NOT NULL + AND t.pdf != '' + ORDER BY m.date DESC + """; + + final queryResult = await customSelect( + query, + variables: [ + Variable.withString(locale), + ], + readsFrom: {messages, translations}, + ).map((row) { + return Draft( + id: row.read('id'), + title: row.read('title'), + date: row.read('date'), + activity: row.read('activity'), + thumbnail: row.read('thumbnail'), + draft: row.read('draft'), + locale: row.read('locale'), + country: row.read('country'), + city: row.read('city'), + body: '', + pdf: row.read('pdf') ?? '', + languagesCode: row.read('languages_code'), + ); + }).get(); + + return queryResult; + } catch (e) { + if (kDebugMode) { + print('Error fetching available PDFs: $e'); + } + return []; + } + } + + // Método para obtener mensajes por IDs + Future> getMessagesByIds(List ids, String locale) async { + if (ids.isEmpty) return []; + + try { + // Convertir la lista de IDs a un formato que pueda usarse en la consulta SQL + final String idList = ids.map((id) => "'$id'").join(','); + + final queryResult = await customSelect( + ''' + SELECT DISTINCT + m.id, + m.date, + m.activity, + m.thumbnail, + m.draft, + m.country, + m.city, + t.languages_code as locale, + t.title, + t.body, + t.pdf, + t.languages_code + FROM + messages m + JOIN translations t ON m.id = t.message_id + WHERE + m.id IN ($idList) AND t.languages_code = ? + ORDER BY m.date DESC + ''', + variables: [Variable.withString(locale)], + readsFrom: {messages, translations}, + ).map((row) { + return Draft( + id: row.read('id'), + title: row.read('title'), + date: row.read('date'), + activity: row.read('activity'), + thumbnail: row.read('thumbnail'), + draft: row.read('draft'), + locale: row.read('locale'), + country: row.read('country'), + city: row.read('city'), + body: row.read('body') ?? '', + pdf: row.read('pdf') ?? '', + languagesCode: row.read('languages_code'), + ); + }).get(); + + if (kDebugMode) { + print( + 'Encontrados ${queryResult.length} mensajes de ${ids.length} IDs'); + } + + return queryResult; + } catch (e) { + if (kDebugMode) { + print('Error obteniendo mensajes por IDs: $e'); + } + return []; + } + } +} diff --git a/lib/database.g.dart b/lib/database.g.dart new file mode 100644 index 0000000..320e459 --- /dev/null +++ b/lib/database.g.dart @@ -0,0 +1,1387 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class $MessagesTable extends Messages + with drift.TableInfo<$MessagesTable, Message> { + @override + final drift.GeneratedDatabase attachedDatabase; + final String? _alias; + $MessagesTable(this.attachedDatabase, [this._alias]); + static const drift.VerificationMeta _idMeta = + const drift.VerificationMeta('id'); + @override + late final drift.GeneratedColumn id = drift.GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const drift.VerificationMeta _countryMeta = + const drift.VerificationMeta('country'); + @override + late final drift.GeneratedColumn country = + drift.GeneratedColumn('country', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const drift.VerificationMeta _cityMeta = + const drift.VerificationMeta('city'); + @override + late final drift.GeneratedColumn city = drift.GeneratedColumn( + 'city', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const drift.VerificationMeta _dateMeta = + const drift.VerificationMeta('date'); + @override + late final drift.GeneratedColumn date = + drift.GeneratedColumn('date', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const drift.VerificationMeta _activityMeta = + const drift.VerificationMeta('activity'); + @override + late final drift.GeneratedColumn activity = drift.GeneratedColumn( + 'activity', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + static const drift.VerificationMeta _draftMeta = + const drift.VerificationMeta('draft'); + @override + late final drift.GeneratedColumn draft = drift.GeneratedColumn( + 'draft', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + static const drift.VerificationMeta _thumbnailMeta = + const drift.VerificationMeta('thumbnail'); + @override + late final drift.GeneratedColumn thumbnail = + drift.GeneratedColumn('thumbnail', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + @override + List get $columns => + [id, country, city, date, activity, draft, thumbnail]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'messages'; + @override + drift.VerificationContext validateIntegrity( + drift.Insertable instance, + {bool isInserting = false}) { + final context = drift.VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('country')) { + context.handle(_countryMeta, + country.isAcceptableOrUnknown(data['country']!, _countryMeta)); + } else if (isInserting) { + context.missing(_countryMeta); + } + if (data.containsKey('city')) { + context.handle( + _cityMeta, city.isAcceptableOrUnknown(data['city']!, _cityMeta)); + } else if (isInserting) { + context.missing(_cityMeta); + } + if (data.containsKey('date')) { + context.handle( + _dateMeta, date.isAcceptableOrUnknown(data['date']!, _dateMeta)); + } else if (isInserting) { + context.missing(_dateMeta); + } + if (data.containsKey('activity')) { + context.handle(_activityMeta, + activity.isAcceptableOrUnknown(data['activity']!, _activityMeta)); + } else if (isInserting) { + context.missing(_activityMeta); + } + if (data.containsKey('draft')) { + context.handle( + _draftMeta, draft.isAcceptableOrUnknown(data['draft']!, _draftMeta)); + } else if (isInserting) { + context.missing(_draftMeta); + } + if (data.containsKey('thumbnail')) { + context.handle(_thumbnailMeta, + thumbnail.isAcceptableOrUnknown(data['thumbnail']!, _thumbnailMeta)); + } else if (isInserting) { + context.missing(_thumbnailMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Message map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Message( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + country: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}country'])!, + city: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}city'])!, + date: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}date'])!, + activity: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}activity'])!, + draft: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}draft'])!, + thumbnail: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}thumbnail'])!, + ); + } + + @override + $MessagesTable createAlias(String alias) { + return $MessagesTable(attachedDatabase, alias); + } +} + +class Message extends drift.DataClass implements drift.Insertable { + final String id; + final String country; + final String city; + final DateTime date; + final int activity; + final int draft; + final String thumbnail; + const Message( + {required this.id, + required this.country, + required this.city, + required this.date, + required this.activity, + required this.draft, + required this.thumbnail}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = drift.Variable(id); + map['country'] = drift.Variable(country); + map['city'] = drift.Variable(city); + map['date'] = drift.Variable(date); + map['activity'] = drift.Variable(activity); + map['draft'] = drift.Variable(draft); + map['thumbnail'] = drift.Variable(thumbnail); + return map; + } + + MessagesCompanion toCompanion(bool nullToAbsent) { + return MessagesCompanion( + id: drift.Value(id), + country: drift.Value(country), + city: drift.Value(city), + date: drift.Value(date), + activity: drift.Value(activity), + draft: drift.Value(draft), + thumbnail: drift.Value(thumbnail), + ); + } + + factory Message.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= drift.driftRuntimeOptions.defaultSerializer; + return Message( + id: serializer.fromJson(json['id']), + country: serializer.fromJson(json['country']), + city: serializer.fromJson(json['city']), + date: serializer.fromJson(json['date']), + activity: serializer.fromJson(json['activity']), + draft: serializer.fromJson(json['draft']), + thumbnail: serializer.fromJson(json['thumbnail']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= drift.driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'country': serializer.toJson(country), + 'city': serializer.toJson(city), + 'date': serializer.toJson(date), + 'activity': serializer.toJson(activity), + 'draft': serializer.toJson(draft), + 'thumbnail': serializer.toJson(thumbnail), + }; + } + + Message copyWith( + {String? id, + String? country, + String? city, + DateTime? date, + int? activity, + int? draft, + String? thumbnail}) => + Message( + id: id ?? this.id, + country: country ?? this.country, + city: city ?? this.city, + date: date ?? this.date, + activity: activity ?? this.activity, + draft: draft ?? this.draft, + thumbnail: thumbnail ?? this.thumbnail, + ); + Message copyWithCompanion(MessagesCompanion data) { + return Message( + id: data.id.present ? data.id.value : this.id, + country: data.country.present ? data.country.value : this.country, + city: data.city.present ? data.city.value : this.city, + date: data.date.present ? data.date.value : this.date, + activity: data.activity.present ? data.activity.value : this.activity, + draft: data.draft.present ? data.draft.value : this.draft, + thumbnail: data.thumbnail.present ? data.thumbnail.value : this.thumbnail, + ); + } + + @override + String toString() { + return (StringBuffer('Message(') + ..write('id: $id, ') + ..write('country: $country, ') + ..write('city: $city, ') + ..write('date: $date, ') + ..write('activity: $activity, ') + ..write('draft: $draft, ') + ..write('thumbnail: $thumbnail') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, country, city, date, activity, draft, thumbnail); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Message && + other.id == this.id && + other.country == this.country && + other.city == this.city && + other.date == this.date && + other.activity == this.activity && + other.draft == this.draft && + other.thumbnail == this.thumbnail); +} + +class MessagesCompanion extends drift.UpdateCompanion { + final drift.Value id; + final drift.Value country; + final drift.Value city; + final drift.Value date; + final drift.Value activity; + final drift.Value draft; + final drift.Value thumbnail; + final drift.Value rowid; + const MessagesCompanion({ + this.id = const drift.Value.absent(), + this.country = const drift.Value.absent(), + this.city = const drift.Value.absent(), + this.date = const drift.Value.absent(), + this.activity = const drift.Value.absent(), + this.draft = const drift.Value.absent(), + this.thumbnail = const drift.Value.absent(), + this.rowid = const drift.Value.absent(), + }); + MessagesCompanion.insert({ + required String id, + required String country, + required String city, + required DateTime date, + required int activity, + required int draft, + required String thumbnail, + this.rowid = const drift.Value.absent(), + }) : id = drift.Value(id), + country = drift.Value(country), + city = drift.Value(city), + date = drift.Value(date), + activity = drift.Value(activity), + draft = drift.Value(draft), + thumbnail = drift.Value(thumbnail); + static drift.Insertable custom({ + drift.Expression? id, + drift.Expression? country, + drift.Expression? city, + drift.Expression? date, + drift.Expression? activity, + drift.Expression? draft, + drift.Expression? thumbnail, + drift.Expression? rowid, + }) { + return drift.RawValuesInsertable({ + if (id != null) 'id': id, + if (country != null) 'country': country, + if (city != null) 'city': city, + if (date != null) 'date': date, + if (activity != null) 'activity': activity, + if (draft != null) 'draft': draft, + if (thumbnail != null) 'thumbnail': thumbnail, + if (rowid != null) 'rowid': rowid, + }); + } + + MessagesCompanion copyWith( + {drift.Value? id, + drift.Value? country, + drift.Value? city, + drift.Value? date, + drift.Value? activity, + drift.Value? draft, + drift.Value? thumbnail, + drift.Value? rowid}) { + return MessagesCompanion( + id: id ?? this.id, + country: country ?? this.country, + city: city ?? this.city, + date: date ?? this.date, + activity: activity ?? this.activity, + draft: draft ?? this.draft, + thumbnail: thumbnail ?? this.thumbnail, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = drift.Variable(id.value); + } + if (country.present) { + map['country'] = drift.Variable(country.value); + } + if (city.present) { + map['city'] = drift.Variable(city.value); + } + if (date.present) { + map['date'] = drift.Variable(date.value); + } + if (activity.present) { + map['activity'] = drift.Variable(activity.value); + } + if (draft.present) { + map['draft'] = drift.Variable(draft.value); + } + if (thumbnail.present) { + map['thumbnail'] = drift.Variable(thumbnail.value); + } + if (rowid.present) { + map['rowid'] = drift.Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MessagesCompanion(') + ..write('id: $id, ') + ..write('country: $country, ') + ..write('city: $city, ') + ..write('date: $date, ') + ..write('activity: $activity, ') + ..write('draft: $draft, ') + ..write('thumbnail: $thumbnail, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $TranslationsTable extends Translations + with drift.TableInfo<$TranslationsTable, Translation> { + @override + final drift.GeneratedDatabase attachedDatabase; + final String? _alias; + $TranslationsTable(this.attachedDatabase, [this._alias]); + static const drift.VerificationMeta _messageIdMeta = + const drift.VerificationMeta('messageId'); + @override + late final drift.GeneratedColumn messageId = + drift.GeneratedColumn('message_id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const drift.VerificationMeta _titleMeta = + const drift.VerificationMeta('title'); + @override + late final drift.GeneratedColumn title = + drift.GeneratedColumn('title', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const drift.VerificationMeta _bodyMeta = + const drift.VerificationMeta('body'); + @override + late final drift.GeneratedColumn body = drift.GeneratedColumn( + 'body', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const drift.VerificationMeta _languagesCodeMeta = + const drift.VerificationMeta('languagesCode'); + @override + late final drift.GeneratedColumn languagesCode = + drift.GeneratedColumn('languages_code', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const drift.VerificationMeta _pdfMeta = + const drift.VerificationMeta('pdf'); + @override + late final drift.GeneratedColumn pdf = drift.GeneratedColumn( + 'pdf', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + @override + List get $columns => + [messageId, title, body, languagesCode, pdf]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'translations'; + @override + drift.VerificationContext validateIntegrity( + drift.Insertable instance, + {bool isInserting = false}) { + final context = drift.VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('message_id')) { + context.handle(_messageIdMeta, + messageId.isAcceptableOrUnknown(data['message_id']!, _messageIdMeta)); + } else if (isInserting) { + context.missing(_messageIdMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, title.isAcceptableOrUnknown(data['title']!, _titleMeta)); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('body')) { + context.handle( + _bodyMeta, body.isAcceptableOrUnknown(data['body']!, _bodyMeta)); + } else if (isInserting) { + context.missing(_bodyMeta); + } + if (data.containsKey('languages_code')) { + context.handle( + _languagesCodeMeta, + languagesCode.isAcceptableOrUnknown( + data['languages_code']!, _languagesCodeMeta)); + } else if (isInserting) { + context.missing(_languagesCodeMeta); + } + if (data.containsKey('pdf')) { + context.handle( + _pdfMeta, pdf.isAcceptableOrUnknown(data['pdf']!, _pdfMeta)); + } + return context; + } + + @override + Set get $primaryKey => {messageId, languagesCode}; + @override + Translation map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Translation( + messageId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}message_id'])!, + title: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}title'])!, + body: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}body'])!, + languagesCode: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}languages_code'])!, + pdf: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}pdf']), + ); + } + + @override + $TranslationsTable createAlias(String alias) { + return $TranslationsTable(attachedDatabase, alias); + } +} + +class Translation extends drift.DataClass + implements drift.Insertable { + final String messageId; + final String title; + final String body; + final String languagesCode; + final String? pdf; + const Translation( + {required this.messageId, + required this.title, + required this.body, + required this.languagesCode, + this.pdf}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['message_id'] = drift.Variable(messageId); + map['title'] = drift.Variable(title); + map['body'] = drift.Variable(body); + map['languages_code'] = drift.Variable(languagesCode); + if (!nullToAbsent || pdf != null) { + map['pdf'] = drift.Variable(pdf); + } + return map; + } + + TranslationsCompanion toCompanion(bool nullToAbsent) { + return TranslationsCompanion( + messageId: drift.Value(messageId), + title: drift.Value(title), + body: drift.Value(body), + languagesCode: drift.Value(languagesCode), + pdf: pdf == null && nullToAbsent + ? const drift.Value.absent() + : drift.Value(pdf), + ); + } + + factory Translation.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= drift.driftRuntimeOptions.defaultSerializer; + return Translation( + messageId: serializer.fromJson(json['messageId']), + title: serializer.fromJson(json['title']), + body: serializer.fromJson(json['body']), + languagesCode: serializer.fromJson(json['languagesCode']), + pdf: serializer.fromJson(json['pdf']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= drift.driftRuntimeOptions.defaultSerializer; + return { + 'messageId': serializer.toJson(messageId), + 'title': serializer.toJson(title), + 'body': serializer.toJson(body), + 'languagesCode': serializer.toJson(languagesCode), + 'pdf': serializer.toJson(pdf), + }; + } + + Translation copyWith( + {String? messageId, + String? title, + String? body, + String? languagesCode, + drift.Value pdf = const drift.Value.absent()}) => + Translation( + messageId: messageId ?? this.messageId, + title: title ?? this.title, + body: body ?? this.body, + languagesCode: languagesCode ?? this.languagesCode, + pdf: pdf.present ? pdf.value : this.pdf, + ); + Translation copyWithCompanion(TranslationsCompanion data) { + return Translation( + messageId: data.messageId.present ? data.messageId.value : this.messageId, + title: data.title.present ? data.title.value : this.title, + body: data.body.present ? data.body.value : this.body, + languagesCode: data.languagesCode.present + ? data.languagesCode.value + : this.languagesCode, + pdf: data.pdf.present ? data.pdf.value : this.pdf, + ); + } + + @override + String toString() { + return (StringBuffer('Translation(') + ..write('messageId: $messageId, ') + ..write('title: $title, ') + ..write('body: $body, ') + ..write('languagesCode: $languagesCode, ') + ..write('pdf: $pdf') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(messageId, title, body, languagesCode, pdf); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Translation && + other.messageId == this.messageId && + other.title == this.title && + other.body == this.body && + other.languagesCode == this.languagesCode && + other.pdf == this.pdf); +} + +class TranslationsCompanion extends drift.UpdateCompanion { + final drift.Value messageId; + final drift.Value title; + final drift.Value body; + final drift.Value languagesCode; + final drift.Value pdf; + final drift.Value rowid; + const TranslationsCompanion({ + this.messageId = const drift.Value.absent(), + this.title = const drift.Value.absent(), + this.body = const drift.Value.absent(), + this.languagesCode = const drift.Value.absent(), + this.pdf = const drift.Value.absent(), + this.rowid = const drift.Value.absent(), + }); + TranslationsCompanion.insert({ + required String messageId, + required String title, + required String body, + required String languagesCode, + this.pdf = const drift.Value.absent(), + this.rowid = const drift.Value.absent(), + }) : messageId = drift.Value(messageId), + title = drift.Value(title), + body = drift.Value(body), + languagesCode = drift.Value(languagesCode); + static drift.Insertable custom({ + drift.Expression? messageId, + drift.Expression? title, + drift.Expression? body, + drift.Expression? languagesCode, + drift.Expression? pdf, + drift.Expression? rowid, + }) { + return drift.RawValuesInsertable({ + if (messageId != null) 'message_id': messageId, + if (title != null) 'title': title, + if (body != null) 'body': body, + if (languagesCode != null) 'languages_code': languagesCode, + if (pdf != null) 'pdf': pdf, + if (rowid != null) 'rowid': rowid, + }); + } + + TranslationsCompanion copyWith( + {drift.Value? messageId, + drift.Value? title, + drift.Value? body, + drift.Value? languagesCode, + drift.Value? pdf, + drift.Value? rowid}) { + return TranslationsCompanion( + messageId: messageId ?? this.messageId, + title: title ?? this.title, + body: body ?? this.body, + languagesCode: languagesCode ?? this.languagesCode, + pdf: pdf ?? this.pdf, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (messageId.present) { + map['message_id'] = drift.Variable(messageId.value); + } + if (title.present) { + map['title'] = drift.Variable(title.value); + } + if (body.present) { + map['body'] = drift.Variable(body.value); + } + if (languagesCode.present) { + map['languages_code'] = drift.Variable(languagesCode.value); + } + if (pdf.present) { + map['pdf'] = drift.Variable(pdf.value); + } + if (rowid.present) { + map['rowid'] = drift.Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TranslationsCompanion(') + ..write('messageId: $messageId, ') + ..write('title: $title, ') + ..write('body: $body, ') + ..write('languagesCode: $languagesCode, ') + ..write('pdf: $pdf, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $FavoritesTable extends Favorites + with drift.TableInfo<$FavoritesTable, Favorite> { + @override + final drift.GeneratedDatabase attachedDatabase; + final String? _alias; + $FavoritesTable(this.attachedDatabase, [this._alias]); + static const drift.VerificationMeta _idMeta = + const drift.VerificationMeta('id'); + @override + late final drift.GeneratedColumn id = drift.GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + @override + List get $columns => [id]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'favorites'; + @override + drift.VerificationContext validateIntegrity( + drift.Insertable instance, + {bool isInserting = false}) { + final context = drift.VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Favorite map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Favorite( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + ); + } + + @override + $FavoritesTable createAlias(String alias) { + return $FavoritesTable(attachedDatabase, alias); + } +} + +class Favorite extends drift.DataClass implements drift.Insertable { + final String id; + const Favorite({required this.id}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = drift.Variable(id); + return map; + } + + FavoritesCompanion toCompanion(bool nullToAbsent) { + return FavoritesCompanion( + id: drift.Value(id), + ); + } + + factory Favorite.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= drift.driftRuntimeOptions.defaultSerializer; + return Favorite( + id: serializer.fromJson(json['id']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= drift.driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + }; + } + + Favorite copyWith({String? id}) => Favorite( + id: id ?? this.id, + ); + Favorite copyWithCompanion(FavoritesCompanion data) { + return Favorite( + id: data.id.present ? data.id.value : this.id, + ); + } + + @override + String toString() { + return (StringBuffer('Favorite(') + ..write('id: $id') + ..write(')')) + .toString(); + } + + @override + int get hashCode => id.hashCode; + @override + bool operator ==(Object other) => + identical(this, other) || (other is Favorite && other.id == this.id); +} + +class FavoritesCompanion extends drift.UpdateCompanion { + final drift.Value id; + final drift.Value rowid; + const FavoritesCompanion({ + this.id = const drift.Value.absent(), + this.rowid = const drift.Value.absent(), + }); + FavoritesCompanion.insert({ + required String id, + this.rowid = const drift.Value.absent(), + }) : id = drift.Value(id); + static drift.Insertable custom({ + drift.Expression? id, + drift.Expression? rowid, + }) { + return drift.RawValuesInsertable({ + if (id != null) 'id': id, + if (rowid != null) 'rowid': rowid, + }); + } + + FavoritesCompanion copyWith( + {drift.Value? id, drift.Value? rowid}) { + return FavoritesCompanion( + id: id ?? this.id, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = drift.Variable(id.value); + } + if (rowid.present) { + map['rowid'] = drift.Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('FavoritesCompanion(') + ..write('id: $id, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends drift.GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $MessagesTable messages = $MessagesTable(this); + late final $TranslationsTable translations = $TranslationsTable(this); + late final $FavoritesTable favorites = $FavoritesTable(this); + late final drift.Index titleIndex = drift.Index( + 'title_index', 'CREATE INDEX title_index ON translations (title)'); + late final drift.Index bodyIndex = drift.Index( + 'body_index', 'CREATE INDEX body_index ON translations (body)'); + late final drift.Index messageIdIndex = drift.Index('message_id_index', + 'CREATE INDEX message_id_index ON translations (message_id)'); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + messages, + translations, + favorites, + titleIndex, + bodyIndex, + messageIdIndex + ]; +} + +typedef $$MessagesTableCreateCompanionBuilder = MessagesCompanion Function({ + required String id, + required String country, + required String city, + required DateTime date, + required int activity, + required int draft, + required String thumbnail, + drift.Value rowid, +}); +typedef $$MessagesTableUpdateCompanionBuilder = MessagesCompanion Function({ + drift.Value id, + drift.Value country, + drift.Value city, + drift.Value date, + drift.Value activity, + drift.Value draft, + drift.Value thumbnail, + drift.Value rowid, +}); + +class $$MessagesTableFilterComposer + extends drift.Composer<_$AppDatabase, $MessagesTable> { + $$MessagesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + drift.ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => drift.ColumnFilters(column)); + + drift.ColumnFilters get country => $composableBuilder( + column: $table.country, builder: (column) => drift.ColumnFilters(column)); + + drift.ColumnFilters get city => $composableBuilder( + column: $table.city, builder: (column) => drift.ColumnFilters(column)); + + drift.ColumnFilters get date => $composableBuilder( + column: $table.date, builder: (column) => drift.ColumnFilters(column)); + + drift.ColumnFilters get activity => $composableBuilder( + column: $table.activity, + builder: (column) => drift.ColumnFilters(column)); + + drift.ColumnFilters get draft => $composableBuilder( + column: $table.draft, builder: (column) => drift.ColumnFilters(column)); + + drift.ColumnFilters get thumbnail => $composableBuilder( + column: $table.thumbnail, + builder: (column) => drift.ColumnFilters(column)); +} + +class $$MessagesTableOrderingComposer + extends drift.Composer<_$AppDatabase, $MessagesTable> { + $$MessagesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + drift.ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => drift.ColumnOrderings(column)); + + drift.ColumnOrderings get country => $composableBuilder( + column: $table.country, + builder: (column) => drift.ColumnOrderings(column)); + + drift.ColumnOrderings get city => $composableBuilder( + column: $table.city, builder: (column) => drift.ColumnOrderings(column)); + + drift.ColumnOrderings get date => $composableBuilder( + column: $table.date, builder: (column) => drift.ColumnOrderings(column)); + + drift.ColumnOrderings get activity => $composableBuilder( + column: $table.activity, + builder: (column) => drift.ColumnOrderings(column)); + + drift.ColumnOrderings get draft => $composableBuilder( + column: $table.draft, builder: (column) => drift.ColumnOrderings(column)); + + drift.ColumnOrderings get thumbnail => $composableBuilder( + column: $table.thumbnail, + builder: (column) => drift.ColumnOrderings(column)); +} + +class $$MessagesTableAnnotationComposer + extends drift.Composer<_$AppDatabase, $MessagesTable> { + $$MessagesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + drift.GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + drift.GeneratedColumn get country => + $composableBuilder(column: $table.country, builder: (column) => column); + + drift.GeneratedColumn get city => + $composableBuilder(column: $table.city, builder: (column) => column); + + drift.GeneratedColumn get date => + $composableBuilder(column: $table.date, builder: (column) => column); + + drift.GeneratedColumn get activity => + $composableBuilder(column: $table.activity, builder: (column) => column); + + drift.GeneratedColumn get draft => + $composableBuilder(column: $table.draft, builder: (column) => column); + + drift.GeneratedColumn get thumbnail => + $composableBuilder(column: $table.thumbnail, builder: (column) => column); +} + +class $$MessagesTableTableManager extends drift.RootTableManager< + _$AppDatabase, + $MessagesTable, + Message, + $$MessagesTableFilterComposer, + $$MessagesTableOrderingComposer, + $$MessagesTableAnnotationComposer, + $$MessagesTableCreateCompanionBuilder, + $$MessagesTableUpdateCompanionBuilder, + (Message, drift.BaseReferences<_$AppDatabase, $MessagesTable, Message>), + Message, + drift.PrefetchHooks Function()> { + $$MessagesTableTableManager(_$AppDatabase db, $MessagesTable table) + : super(drift.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$MessagesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$MessagesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$MessagesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + drift.Value id = const drift.Value.absent(), + drift.Value country = const drift.Value.absent(), + drift.Value city = const drift.Value.absent(), + drift.Value date = const drift.Value.absent(), + drift.Value activity = const drift.Value.absent(), + drift.Value draft = const drift.Value.absent(), + drift.Value thumbnail = const drift.Value.absent(), + drift.Value rowid = const drift.Value.absent(), + }) => + MessagesCompanion( + id: id, + country: country, + city: city, + date: date, + activity: activity, + draft: draft, + thumbnail: thumbnail, + rowid: rowid, + ), + createCompanionCallback: ({ + required String id, + required String country, + required String city, + required DateTime date, + required int activity, + required int draft, + required String thumbnail, + drift.Value rowid = const drift.Value.absent(), + }) => + MessagesCompanion.insert( + id: id, + country: country, + city: city, + date: date, + activity: activity, + draft: draft, + thumbnail: thumbnail, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => + (e.readTable(table), drift.BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$MessagesTableProcessedTableManager = drift.ProcessedTableManager< + _$AppDatabase, + $MessagesTable, + Message, + $$MessagesTableFilterComposer, + $$MessagesTableOrderingComposer, + $$MessagesTableAnnotationComposer, + $$MessagesTableCreateCompanionBuilder, + $$MessagesTableUpdateCompanionBuilder, + (Message, drift.BaseReferences<_$AppDatabase, $MessagesTable, Message>), + Message, + drift.PrefetchHooks Function()>; +typedef $$TranslationsTableCreateCompanionBuilder = TranslationsCompanion + Function({ + required String messageId, + required String title, + required String body, + required String languagesCode, + drift.Value pdf, + drift.Value rowid, +}); +typedef $$TranslationsTableUpdateCompanionBuilder = TranslationsCompanion + Function({ + drift.Value messageId, + drift.Value title, + drift.Value body, + drift.Value languagesCode, + drift.Value pdf, + drift.Value rowid, +}); + +class $$TranslationsTableFilterComposer + extends drift.Composer<_$AppDatabase, $TranslationsTable> { + $$TranslationsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + drift.ColumnFilters get messageId => $composableBuilder( + column: $table.messageId, + builder: (column) => drift.ColumnFilters(column)); + + drift.ColumnFilters get title => $composableBuilder( + column: $table.title, builder: (column) => drift.ColumnFilters(column)); + + drift.ColumnFilters get body => $composableBuilder( + column: $table.body, builder: (column) => drift.ColumnFilters(column)); + + drift.ColumnFilters get languagesCode => $composableBuilder( + column: $table.languagesCode, + builder: (column) => drift.ColumnFilters(column)); + + drift.ColumnFilters get pdf => $composableBuilder( + column: $table.pdf, builder: (column) => drift.ColumnFilters(column)); +} + +class $$TranslationsTableOrderingComposer + extends drift.Composer<_$AppDatabase, $TranslationsTable> { + $$TranslationsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + drift.ColumnOrderings get messageId => $composableBuilder( + column: $table.messageId, + builder: (column) => drift.ColumnOrderings(column)); + + drift.ColumnOrderings get title => $composableBuilder( + column: $table.title, builder: (column) => drift.ColumnOrderings(column)); + + drift.ColumnOrderings get body => $composableBuilder( + column: $table.body, builder: (column) => drift.ColumnOrderings(column)); + + drift.ColumnOrderings get languagesCode => $composableBuilder( + column: $table.languagesCode, + builder: (column) => drift.ColumnOrderings(column)); + + drift.ColumnOrderings get pdf => $composableBuilder( + column: $table.pdf, builder: (column) => drift.ColumnOrderings(column)); +} + +class $$TranslationsTableAnnotationComposer + extends drift.Composer<_$AppDatabase, $TranslationsTable> { + $$TranslationsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + drift.GeneratedColumn get messageId => + $composableBuilder(column: $table.messageId, builder: (column) => column); + + drift.GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + drift.GeneratedColumn get body => + $composableBuilder(column: $table.body, builder: (column) => column); + + drift.GeneratedColumn get languagesCode => $composableBuilder( + column: $table.languagesCode, builder: (column) => column); + + drift.GeneratedColumn get pdf => + $composableBuilder(column: $table.pdf, builder: (column) => column); +} + +class $$TranslationsTableTableManager extends drift.RootTableManager< + _$AppDatabase, + $TranslationsTable, + Translation, + $$TranslationsTableFilterComposer, + $$TranslationsTableOrderingComposer, + $$TranslationsTableAnnotationComposer, + $$TranslationsTableCreateCompanionBuilder, + $$TranslationsTableUpdateCompanionBuilder, + ( + Translation, + drift.BaseReferences<_$AppDatabase, $TranslationsTable, Translation> + ), + Translation, + drift.PrefetchHooks Function()> { + $$TranslationsTableTableManager(_$AppDatabase db, $TranslationsTable table) + : super(drift.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$TranslationsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$TranslationsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$TranslationsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + drift.Value messageId = const drift.Value.absent(), + drift.Value title = const drift.Value.absent(), + drift.Value body = const drift.Value.absent(), + drift.Value languagesCode = const drift.Value.absent(), + drift.Value pdf = const drift.Value.absent(), + drift.Value rowid = const drift.Value.absent(), + }) => + TranslationsCompanion( + messageId: messageId, + title: title, + body: body, + languagesCode: languagesCode, + pdf: pdf, + rowid: rowid, + ), + createCompanionCallback: ({ + required String messageId, + required String title, + required String body, + required String languagesCode, + drift.Value pdf = const drift.Value.absent(), + drift.Value rowid = const drift.Value.absent(), + }) => + TranslationsCompanion.insert( + messageId: messageId, + title: title, + body: body, + languagesCode: languagesCode, + pdf: pdf, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => + (e.readTable(table), drift.BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$TranslationsTableProcessedTableManager = drift.ProcessedTableManager< + _$AppDatabase, + $TranslationsTable, + Translation, + $$TranslationsTableFilterComposer, + $$TranslationsTableOrderingComposer, + $$TranslationsTableAnnotationComposer, + $$TranslationsTableCreateCompanionBuilder, + $$TranslationsTableUpdateCompanionBuilder, + ( + Translation, + drift.BaseReferences<_$AppDatabase, $TranslationsTable, Translation> + ), + Translation, + drift.PrefetchHooks Function()>; +typedef $$FavoritesTableCreateCompanionBuilder = FavoritesCompanion Function({ + required String id, + drift.Value rowid, +}); +typedef $$FavoritesTableUpdateCompanionBuilder = FavoritesCompanion Function({ + drift.Value id, + drift.Value rowid, +}); + +class $$FavoritesTableFilterComposer + extends drift.Composer<_$AppDatabase, $FavoritesTable> { + $$FavoritesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + drift.ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => drift.ColumnFilters(column)); +} + +class $$FavoritesTableOrderingComposer + extends drift.Composer<_$AppDatabase, $FavoritesTable> { + $$FavoritesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + drift.ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => drift.ColumnOrderings(column)); +} + +class $$FavoritesTableAnnotationComposer + extends drift.Composer<_$AppDatabase, $FavoritesTable> { + $$FavoritesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + drift.GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); +} + +class $$FavoritesTableTableManager extends drift.RootTableManager< + _$AppDatabase, + $FavoritesTable, + Favorite, + $$FavoritesTableFilterComposer, + $$FavoritesTableOrderingComposer, + $$FavoritesTableAnnotationComposer, + $$FavoritesTableCreateCompanionBuilder, + $$FavoritesTableUpdateCompanionBuilder, + (Favorite, drift.BaseReferences<_$AppDatabase, $FavoritesTable, Favorite>), + Favorite, + drift.PrefetchHooks Function()> { + $$FavoritesTableTableManager(_$AppDatabase db, $FavoritesTable table) + : super(drift.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$FavoritesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$FavoritesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$FavoritesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + drift.Value id = const drift.Value.absent(), + drift.Value rowid = const drift.Value.absent(), + }) => + FavoritesCompanion( + id: id, + rowid: rowid, + ), + createCompanionCallback: ({ + required String id, + drift.Value rowid = const drift.Value.absent(), + }) => + FavoritesCompanion.insert( + id: id, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => + (e.readTable(table), drift.BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$FavoritesTableProcessedTableManager = drift.ProcessedTableManager< + _$AppDatabase, + $FavoritesTable, + Favorite, + $$FavoritesTableFilterComposer, + $$FavoritesTableOrderingComposer, + $$FavoritesTableAnnotationComposer, + $$FavoritesTableCreateCompanionBuilder, + $$FavoritesTableUpdateCompanionBuilder, + (Favorite, drift.BaseReferences<_$AppDatabase, $FavoritesTable, Favorite>), + Favorite, + drift.PrefetchHooks Function()>; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$MessagesTableTableManager get messages => + $$MessagesTableTableManager(_db, _db.messages); + $$TranslationsTableTableManager get translations => + $$TranslationsTableTableManager(_db, _db.translations); + $$FavoritesTableTableManager get favorites => + $$FavoritesTableTableManager(_db, _db.favorites); +} diff --git a/lib/screens/config.dart b/lib/screens/config.dart new file mode 100644 index 0000000..1af939e --- /dev/null +++ b/lib/screens/config.dart @@ -0,0 +1,667 @@ +import 'dart:io'; +import 'dart:math' as math; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; +import 'package:search_engine/database.dart'; +import 'package:search_engine/screens/landing.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:filesize/filesize.dart'; +import 'package:search_engine/services/config_service.dart'; +import 'package:drift/drift.dart' as drift; + +// Clase para almacenar información de categorías de almacenamiento +class StorageCategory { + final String name; + final IconData icon; + final Color color; + int size; + double percentage; + + StorageCategory({ + required this.name, + required this.icon, + required this.color, + this.size = 0, + this.percentage = 0, + }); +} + +// Clase para pintar el gráfico circular +class StorageChartPainter extends CustomPainter { + final List categories; + + StorageChartPainter(this.categories); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = math.min(size.width, size.height) / 2; + final rect = Rect.fromCircle(center: center, radius: radius); + + double startAngle = -math.pi / 2; + + for (var category in categories) { + if (category.percentage > 0) { + final sweepAngle = (category.percentage / 100) * 2 * math.pi; + final paint = Paint() + ..color = category.color + ..style = PaintingStyle.fill; + + canvas.drawArc(rect, startAngle, sweepAngle, true, paint); + startAngle += sweepAngle; + } + } + + // Dibujar círculo central + canvas.drawCircle( + center, + radius * 0.6, + Paint()..color = Colors.white, + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} + +class ConfigView extends StatefulWidget { + const ConfigView({super.key}); + + @override + State createState() => _ConfigViewState(); +} + +class _ConfigViewState extends State { + bool hdThumbnail = false; + bool pdfDownload = false; + bool isSavingHdThumbnail = false; + bool isSavingPdfDownload = false; + bool isCalculatingCache = false; + bool isCleaningCache = false; + String selectedLocale = 'es'; + String cacheSize = '0 B'; + late Future _databaseFuture; + final String _version = dotenv.env['VERSION'] ?? '1.0'; + + // Lista de categorías de almacenamiento + final List _categories = [ + StorageCategory( + name: 'thumbnails', + icon: Icons.image, + color: Colors.blue, + ), + StorageCategory( + name: 'pdfs', + icon: Icons.picture_as_pdf, + color: Colors.green, + ), + StorageCategory( + name: 'other', + icon: Icons.folder, + color: Colors.orange, + ), + ]; + + final Map locales = { + 'es': 'Español', + 'en': 'English', + 'pt': 'Português', + 'fr': 'Français', + 'rw': 'Kinyarwanda', + }; + + @override + void initState() { + super.initState(); + _databaseFuture = Future.value(AppDatabase()); + _getConfig(); + _calculateCacheSize(); + } + + @override + void dispose() { + _databaseFuture.then((database) => database.close()); + super.dispose(); + } + + Future _calculateCacheSize() async { + setState(() { + isCalculatingCache = true; + }); + + try { + final appDir = await getApplicationDocumentsDirectory(); + final searchDir = Directory('${appDir.path}/LGCC_Search'); + + if (!await searchDir.exists()) { + setState(() { + cacheSize = '0 B'; + isCalculatingCache = false; + }); + return; + } + + int totalSize = 0; + Map categorySizes = { + 'thumbnails': 0, + 'pdfs': 0, + 'other': 0, + }; + + await for (var entity in searchDir.list(recursive: true)) { + if (entity is File && !entity.path.contains('/internal/')) { + final size = await entity.length(); + totalSize += size; + + if (entity.path.endsWith('.jpg')) { + categorySizes['thumbnails'] = + (categorySizes['thumbnails'] ?? 0) + size; + } else if (entity.path.endsWith('.pdf')) { + categorySizes['pdfs'] = (categorySizes['pdfs'] ?? 0) + size; + } else { + categorySizes['other'] = (categorySizes['other'] ?? 0) + size; + } + } + } + + // Actualizar tamaños y porcentajes de categorías + for (var category in _categories) { + category.size = categorySizes[category.name] ?? 0; + category.percentage = + totalSize > 0 ? (category.size / totalSize) * 100 : 0; + } + + setState(() { + cacheSize = filesize(totalSize); + isCalculatingCache = false; + }); + } catch (e) { + setState(() { + cacheSize = 'Error'; + isCalculatingCache = false; + }); + } + } + + Future _clearCache() async { + setState(() { + isCleaningCache = true; + }); + + try { + final appDir = await getApplicationDocumentsDirectory(); + final searchDir = Directory('${appDir.path}/LGCC_Search'); + + if (await searchDir.exists()) { + await for (var entity in searchDir.list(recursive: true)) { + if (entity is File && !entity.path.contains('/internal/')) { + await entity.delete(); + } else if (entity is Directory && + !entity.path.contains('/internal/')) { + if (await entity.exists() && entity.listSync().isEmpty) { + await entity.delete(); + } + } + } + } + + await _calculateCacheSize(); + } catch (e) { + // Handle error + } finally { + setState(() { + isCleaningCache = false; + }); + } + } + + // Widget para mostrar el gráfico circular + Widget _buildStorageChart() { + return SizedBox( + height: 200, + child: Stack( + alignment: Alignment.center, + children: [ + CustomPaint( + size: const Size(200, 200), + painter: StorageChartPainter(_categories), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + cacheSize, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'storage_used'.tr(), + style: const TextStyle( + fontSize: 14, + color: Colors.grey, + ), + ), + ], + ), + ], + ), + ); + } + + // Widget para mostrar la lista de categorías + Widget _buildCategoryList() { + return Column( + children: _categories.map((category) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: category.color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Icon( + category.icon, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + category.name.tr(), + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + Text( + filesize(category.size), + style: TextStyle( + color: Colors.grey[600], + fontSize: 13, + ), + ), + ], + ), + const SizedBox(height: 6), + LinearProgressIndicator( + value: category.percentage / 100, + backgroundColor: category.color.withOpacity(0.1), + valueColor: + AlwaysStoppedAnimation(category.color), + minHeight: 4, + borderRadius: BorderRadius.circular(2), + ), + ], + ), + ), + ], + ), + ), + if (_categories.last != category) const SizedBox(height: 8), + ], + ); + }).toList(), + ); + } + + Future _getConfig() async { + final hd = await ConfigService.getHdThumbnails(); + setState(() { + hdThumbnail = hd; + }); + final pdf = await ConfigService.getPdfDownload(); + setState(() { + pdfDownload = pdf; + }); + final locale = await ConfigService.getLocale(); + setState(() { + selectedLocale = locale; + }); + } + + Future _updateConfig(String key, dynamic value) async { + setState(() { + if (key == 'hd_thumbnails') { + isSavingHdThumbnail = true; + } else if (key == 'pdf_download') { + isSavingPdfDownload = true; + } + }); + + if (key == 'hd_thumbnails') { + await ConfigService.setHdThumbnails(value); + } else if (key == 'pdf_download') { + await ConfigService.setPdfDownload(value); + } else if (key == 'locale') { + await ConfigService.setLocale(value); + } + + setState(() { + if (key == 'hd_thumbnails') { + isSavingHdThumbnail = false; + hdThumbnail = value; + } else if (key == 'pdf_download') { + isSavingPdfDownload = false; + pdfDownload = value; + } else if (key == 'locale') { + selectedLocale = value; + } + }); + } + + Future _confirmLocaleChange(String newValue) async { + final currentContext = context; + final bool? confirm = await showDialog( + context: currentContext, + builder: (BuildContext context) { + return AlertDialog( + title: Text('change_language'.tr()), + content: Text('change_language_confirm'.tr()), + actions: [ + TextButton( + child: Text('no'.tr()), + onPressed: () => Navigator.of(context).pop(false), + ), + TextButton( + child: Text('yes'.tr()), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ); + }, + ); + + if (confirm == true) { + // Mostrar diálogo de progreso + if (mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: Text('updating_data'.tr()), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text('processing_data'.tr()), + ], + ), + ); + }, + ); + } + + try { + // Actualizar el idioma + await _updateConfig('locale', newValue); + + // Obtener la base de datos + final database = await _databaseFuture; + + // Eliminar traducciones que no correspondan al nuevo idioma + await database.customSelect( + 'DELETE FROM translations WHERE languages_code != ?', + variables: [drift.Variable.withString(newValue)], + ).get(); + + // Reiniciar la fecha para el nuevo idioma + await ConfigService.setLastDate(newValue, '0'); + + if (mounted) { + // Cerrar el diálogo de progreso + Navigator.of(context).pop(); + + // Reiniciar la aplicación + currentContext.setLocale(Locale(newValue)).then((_) { + pushReplacementWithoutNavBar(context, + MaterialPageRoute(builder: (context) => const LandingPage())); + }); + } + } catch (e) { + if (mounted) { + // Cerrar el diálogo de progreso + Navigator.of(context).pop(); + + // Mostrar error + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('sync_error'.tr()), + backgroundColor: Colors.red, + ), + ); + } + } + } + } + + void _confirmClearCache() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('clear_cache'.tr()), + content: Text('clear_cache_desc'.tr()), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('no'.tr()), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _clearCache(); + }, + child: Text('yes'.tr()), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFFffffff), Color(0xFFe3ead6)], + begin: Alignment.topRight, + end: Alignment.bottomLeft, + ), + ), + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: Text( + 'config'.tr(), + style: const TextStyle( + fontSize: 24, + fontFamily: 'Outfit', + fontWeight: FontWeight.w700, + ), + ), + iconTheme: const IconThemeData(color: Colors.black, size: 20), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'storage_usage'.tr(), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + Text( + cacheSize, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0XFF6b8b66), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'storage_usage_desc'.tr(), + style: const TextStyle( + fontSize: 14, + color: Colors.grey, + ), + ), + const SizedBox(height: 24), + if (isCalculatingCache) + const LinearProgressIndicator( + backgroundColor: Color(0xFFdce2ca), + valueColor: + AlwaysStoppedAnimation(Color(0XFF6b8b66)), + ) + else ...[ + _buildCategoryList(), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: Row( + children: [ + Icon( + Icons.high_quality_outlined, + size: 20, + color: Colors.grey[600], + ), + const SizedBox(width: 8), + Text( + 'hd_thumbnails'.tr(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + Switch( + value: hdThumbnail, + onChanged: isSavingHdThumbnail + ? null + : (val) => _updateConfig('hd_thumbnails', val), + activeColor: const Color(0XFF6b8b66), + activeTrackColor: const Color(0XFFdce2ca), + inactiveThumbColor: Colors.grey, + inactiveTrackColor: Colors.grey.shade100, + ), + ], + ), + Text( + 'hd_thumbnails_desc'.tr(), + style: TextStyle( + fontSize: 13, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: TextButton.icon( + onPressed: isCleaningCache ? null : _confirmClearCache, + icon: const Icon(Icons.delete_outline), + label: Text('clear_cache'.tr()), + style: TextButton.styleFrom( + foregroundColor: Colors.red, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + const SizedBox(height: 32), + DropdownButtonFormField( + value: selectedLocale, + icon: const Icon(Icons.language, color: Colors.grey), + borderRadius: BorderRadius.circular(8), + dropdownColor: const Color(0xFFf1f4ea), + onChanged: (newValue) { + if (newValue != null) { + _confirmLocaleChange(newValue); + } + }, + items: locales.entries.map((entry) { + return DropdownMenuItem( + value: entry.key, + child: Text(entry.value), + ); + }).toList(), + decoration: InputDecoration( + labelText: 'locale'.tr(), + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 40), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "title".tr(), + style: const TextStyle( + fontSize: 52, + fontWeight: FontWeight.bold, + height: 1, + ), + ), + Text( + '${'version'.tr()} $_version', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w200, + ), + ), + const SizedBox(height: 20), + Center( + child: SvgPicture.asset( + 'assets/svg/logo.svg', + height: 30, + width: 30, + fit: BoxFit.cover, + placeholderBuilder: (context) => + const CircularProgressIndicator(), + semanticsLabel: 'Logo LGCC', + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/content.dart b/lib/screens/content.dart new file mode 100644 index 0000000..493eeb4 --- /dev/null +++ b/lib/screens/content.dart @@ -0,0 +1,2452 @@ +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; +} diff --git a/lib/screens/generic_search.dart b/lib/screens/generic_search.dart new file mode 100644 index 0000000..158f1e4 --- /dev/null +++ b/lib/screens/generic_search.dart @@ -0,0 +1,1334 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:collection'; +import 'dart:math' as math; + +import 'package:country_codes/country_codes.dart'; +import 'package:dio/dio.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:search_engine/screens/content.dart'; +import 'package:search_engine/widgets/base.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import '../database.dart'; +import 'package:search_engine/utils.dart' as utils; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart' + as nav; +// ignore: library_prefixes +import 'package:search_engine/services/notification_service.dart'; + +// Clase auxiliar para representar una coincidencia +class _Match { + final int start; + final int end; + final String text; + + _Match({required this.start, required this.end, required this.text}); +} + +// Clase auxiliar para representar un fragmento de texto +class _TextFragment { + final String beforeMatch; + final String matchText; + final String afterMatch; + + _TextFragment({ + required this.beforeMatch, + required this.matchText, + required this.afterMatch, + }); +} + +// Clase para el caché de búsqueda +class SearchCache { + // LRU Cache con capacidad máxima + final int _maxSize; + final LinkedHashMap> _cache = LinkedHashMap(); + final Map _totalResultsCache = {}; + // Almacena todos los resultados de una búsqueda + final Map> _allResultsCache = {}; + + SearchCache({int maxSize = 10}) : _maxSize = maxSize; + + // Obtener resultados del caché + List? get(String query) { + final normalizedQuery = query.trim().toLowerCase(); + if (!_cache.containsKey(normalizedQuery)) return null; + + // Mover el elemento al final (más reciente) + final results = _cache.remove(normalizedQuery); + _cache[normalizedQuery] = results!; + return results; + } + + // Guardar resultados en el caché + void put(String query, List results) { + final normalizedQuery = query.trim().toLowerCase(); + + // Si el caché está lleno, eliminar el elemento más antiguo + if (_cache.length >= _maxSize) { + final oldestKey = _cache.keys.first; + _cache.remove(oldestKey); + // También eliminar del caché de todos los resultados + _allResultsCache.remove(oldestKey); + _totalResultsCache.remove(oldestKey); + } + + _cache[normalizedQuery] = results; + } + + // Limpiar el caché + void clear() { + _cache.clear(); + _totalResultsCache.clear(); + _allResultsCache.clear(); + } + + // Obtener el total de resultados para una consulta + int? getTotalResults(String query) { + final normalizedQuery = query.trim().toLowerCase(); + return _totalResultsCache[normalizedQuery]; + } + + // Guardar el total de resultados para una consulta + void setTotalResults(String query, int total) { + final normalizedQuery = query.trim().toLowerCase(); + _totalResultsCache[normalizedQuery] = total; + } + + // Guardar todos los resultados de una búsqueda + void putAllResults(String query, List results) { + final normalizedQuery = query.trim().toLowerCase(); + _allResultsCache[normalizedQuery] = results; + } + + // Obtener todos los resultados de una búsqueda + List? getAllResults(String query) { + final normalizedQuery = query.trim().toLowerCase(); + return _allResultsCache[normalizedQuery]; + } +} + +class GenericSearchPage extends StatefulWidget { + final String? searchTerm; + final String title; + final String hintText; + final Future> Function(String query, int pageKey, int offset) + searchFunction; + final Widget Function(Draft message, String currentQuery)? customItemBuilder; + final Widget? emptyStateWidget; + final Widget? loadingWidget; + final Widget? errorWidget; + + const GenericSearchPage({ + super.key, + this.searchTerm, + required this.title, + required this.hintText, + required this.searchFunction, + this.customItemBuilder, + this.emptyStateWidget, + this.loadingWidget, + this.errorWidget, + }); + + @override + _GenericSearchPageState createState() => _GenericSearchPageState(); +} + +class _GenericSearchPageState extends State + with TickerProviderStateMixin { + late TextEditingController _searchController; + late AppDatabase database; + late Directory appDirectory; + int resultCount = 0; + int totalResults = 0; + bool isLoading = false; + // Nuevas variables para separar las fases de carga + bool dataLoading = false; // Para la fase de carga de datos + Timer? _debounce; + final _baseUrl = dotenv.env['BASE_URL']; + final _token = dotenv.env['TOKEN']; + bool _isSearching = false; + late AnimationController _animationController; + late Animation _fadeAnimation; + + // Controlador para la animación de pulso del indicador de búsqueda + late AnimationController _pulseController; + late Animation _pulseAnimation; + + // Controlador de paginación + final PagingController _pagingController = PagingController( + firstPageKey: 1, + // Set invisibleItemsThreshold to load the next page earlier + invisibleItemsThreshold: 5, + ); + + // Tamaño de página para la carga incremental + static const _pageSize = 20; + + // Consulta actual + String _currentQuery = ''; + + // Almacena todos los resultados de la búsqueda actual + List? _allSearchResults; + + // Instancia del caché de búsqueda + final SearchCache _searchCache = SearchCache(maxSize: 20); + + // Caché para nombres de países + final Map _countryNameCache = {}; + + // Caché para los spans resaltados + final Map> _highlightedSpansCache = {}; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(text: widget.searchTerm); + _initAppDirectory(); + database = AppDatabase(); + + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + + _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeIn, + ), + ); + + // Inicializar el controlador de la animación de pulso + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(reverse: true); + + _pulseAnimation = Tween(begin: 0.8, end: 1.2).animate( + CurvedAnimation( + parent: _pulseController, + curve: Curves.easeInOut, + ), + ); + + // Configurar el listener para la paginación + _pagingController.addPageRequestListener((pageKey) { + if (_currentQuery.isNotEmpty) { + _fetchPage(pageKey); + } + }); + + if (_searchController.text.isNotEmpty) { + Timer(const Duration(milliseconds: 100), () { + _onSearch(_searchController.text); + }); + } + } + + @override + void dispose() { + _pagingController.dispose(); + _animationController.dispose(); + _pulseController.dispose(); + database.close(); + _debounce?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + void _onSearch(String value) { + if (_searchController.text.trim().isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _isSearching = true; + _currentQuery = _searchController.text; + totalResults = 0; + isLoading = true; + dataLoading = true; + _allSearchResults = null; + }); + + // Limpiar caché de resaltado para la nueva búsqueda + _highlightedSpansCache.clear(); + + // Refrescar el controlador de paginación + _pagingController.refresh(); + _animationController.forward(); + } + }); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _isSearching = false; + _currentQuery = ''; + totalResults = 0; + isLoading = false; + dataLoading = false; + _allSearchResults = null; + }); + + // Limpiar caché de resaltado + _highlightedSpansCache.clear(); + + // Limpiar y refrescar el controlador de paginación + _pagingController.itemList?.clear(); + _pagingController.refresh(); + + if (_animationController.isAnimating) { + _animationController.stop(); + } + if (_animationController.value > 0) { + _animationController.reverse(); + } + } + }); + } + } + + void _clearSearch() { + _searchController.clear(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _isSearching = false; + _currentQuery = ''; + isLoading = false; + dataLoading = false; + _allSearchResults = null; + }); + + // Limpiar caché de resaltado + _highlightedSpansCache.clear(); + + // Refrescar el controlador de paginación + _pagingController.refresh(); + + if (_animationController.isAnimating) { + _animationController.stop(); + } + if (_animationController.value > 0) { + _animationController.reverse(); + } + } + }); + } + + Future _fetchPage(int pageKey) async { + if (!mounted) return; + + try { + // Verificar si los resultados están en caché + final cachedResults = _searchCache.get('${_currentQuery}_page_$pageKey'); + + if (cachedResults != null) { + final isLastPage = cachedResults.length < _pageSize; + if (isLastPage) { + _pagingController.appendLastPage(cachedResults); + } else { + _pagingController.appendPage(cachedResults, pageKey + 1); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + resultCount = _pagingController.itemList?.length ?? 0; + }); + } + }); + + return; + } + + // Calcular el offset y límite para la paginación + final offset = (pageKey - 1) * _pageSize; + final limit = _pageSize; + + // Indicar que estamos cargando datos + if (pageKey == 1) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + isLoading = true; + dataLoading = true; + }); + } + }); + } + + // Get the RootIsolateToken before starting the search + final token = RootIsolateToken.instance; + if (token == null) { + throw Exception('RootIsolateToken is not initialized'); + } + + // Si ya tenemos todos los resultados, usamos paginación local + if (_allSearchResults != null) { + final int start = offset; + final int end = math.min(start + limit, _allSearchResults!.length); + + if (start >= _allSearchResults!.length) { + // No hay más resultados + _pagingController.appendLastPage([]); + } else { + final pageResults = _allSearchResults!.sublist(start, end); + final bool isLastPage = end >= _allSearchResults!.length; + + if (isLastPage) { + _pagingController.appendLastPage(pageResults); + } else { + _pagingController.appendPage(pageResults, pageKey + 1); + } + + // Actualizar el conteo de resultados + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + isLoading = false; + dataLoading = false; + totalResults = _allSearchResults!.length; + resultCount = (_pagingController.itemList?.length ?? 0); + }); + } + }); + } + return; + } + + // Si no tenemos los resultados completos, realizar la búsqueda + final searchFuture = + widget.searchFunction(_currentQuery, pageKey, offset); + final pageResults = await searchFuture; + + // Si es la primera página, cargar todos los resultados + if (pageKey == 1) { + // Obtener todos los resultados completos (no solo la primera página) + try { + // Extraer la lista de IDs de todos los resultados + Map searchResult = pageResults.isNotEmpty + ? (pageResults.first as dynamic).searchResultData ?? {} + : {}; + + List allResultIds = []; + if (searchResult.containsKey('allResultIds')) { + allResultIds = List.from(searchResult['allResultIds']); + } + + if (allResultIds.isNotEmpty) { + _allSearchResults = await database.getMessagesByIds( + allResultIds, context.locale.toString()); + + totalResults = _allSearchResults?.length ?? 0; + + if (_allSearchResults != null) { + _searchCache.putAllResults(_currentQuery, _allSearchResults!); + _searchCache.setTotalResults( + _currentQuery, _allSearchResults!.length); + } + } + } catch (e) { + if (kDebugMode) { + print('Error cargando todos los resultados: $e'); + } + // Continuar con la paginación normal si falla + _allSearchResults = null; + } + } + + // Si no pudimos cargar todos los resultados, continuar con la paginación normal + if (_allSearchResults == null) { + // Obtener resultados únicos por ID de documento + final uniqueResults = pageResults + .fold>({}, (map, result) { + if (!map.containsKey(result.id)) { + map[result.id] = result; + } + return map; + }) + .values + .toList(); + + // Actualizar el total de resultados si es la primera página + if (pageKey == 1) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + totalResults = uniqueResults.length; + dataLoading = false; + }); + } + }); + } + + // Guardar esta página en caché + _searchCache.put('${_currentQuery}_page_$pageKey', uniqueResults); + + // Mostrar los resultados + final isLastPage = uniqueResults.length < _pageSize; + if (isLastPage) { + _pagingController.appendLastPage(uniqueResults); + } else { + _pagingController.appendPage(uniqueResults, pageKey + 1); + } + } else { + // Usar los resultados completos para la primera página + final int start = 0; + final int end = math.min(limit, _allSearchResults!.length); + final firstPageResults = _allSearchResults!.sublist(start, end); + + // Guardar esta página en caché + _searchCache.put('${_currentQuery}_page_1', firstPageResults); + + final bool isLastPage = end >= _allSearchResults!.length; + if (isLastPage) { + _pagingController.appendLastPage(firstPageResults); + } else { + _pagingController.appendPage(firstPageResults, pageKey + 1); + } + } + + // Actualizar el estado después del frame + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + isLoading = false; + dataLoading = false; + resultCount = (_pagingController.itemList?.length ?? 0); + }); + + if (pageKey == 1) { + if (_animationController.isCompleted) { + _animationController.reset(); + } + _animationController.forward(); + } + } + }); + } catch (e) { + if (kDebugMode) { + print('Error en paginación: $e'); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + isLoading = false; + dataLoading = false; + }); + _pagingController.error = e; + } + }); + } + } + + Future _initAppDirectory() async { + appDirectory = await getApplicationDocumentsDirectory(); + } + + Future _getThumbnail(String directoryPath, String fileId) async { + final dir = Directory(directoryPath); + + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + final entities = await dir.list().toList(); + + for (var entity in entities) { + if (entity is File) { + final fileName = entity.uri.pathSegments.last; + if (fileName == '$fileId+SD.jpg') { + return entity; + } + } + } + + final String url = + '$_baseUrl/assets/$fileId?access_token=$_token&width=320&height=180&quality=50&fit=cover&format=jpg'; + final String thumbnailPath = '$directoryPath/$fileId+SD.jpg'; + if (fileId == '') { + return null; + } + try { + await Dio().download(url, thumbnailPath); + return File(thumbnailPath); + } catch (e) { + if (kDebugMode) { + print('Error downloading thumbnail: $e'); + } + return null; + } + } + + Widget _buildThumbnail(String thumbnailId) { + return FutureBuilder( + future: _getThumbnail( + '${appDirectory.path}/LGCC_Search/${context.locale}/thumbnails/', + thumbnailId), + builder: (context, snapshot) { + return Skeletonizer( + enableSwitchAnimation: true, + enabled: snapshot.connectionState != ConnectionState.done, + effect: const ShimmerEffect( + baseColor: Color(0xFFf1f5eb), + highlightColor: Colors.white30, + duration: Duration(milliseconds: 1000), + ), + child: snapshot.hasData && snapshot.data != null + ? Image.file( + snapshot.data!, + height: double.infinity, + width: double.infinity, + fit: BoxFit.cover, + cacheHeight: 180, + cacheWidth: 320, + ) + : Image.asset( + 'assets/image/default_thumbnail.jpg', + height: double.infinity, + width: double.infinity, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + cacheHeight: 180, + cacheWidth: 320, + ), + ); + }, + ); + } + + List _getHighlightedSpans(String text, String searchText) { + if (searchText.isEmpty) { + return [TextSpan(text: text)]; + } + + // El texto ya está limpio de HTML, podemos usarlo directamente + final String plainText = text; + + // Si el texto es muy corto, no procesarlo + if (plainText.length < 3) { + return [TextSpan(text: plainText)]; + } + + // Caché de resultados para evitar recálculos + final String cacheKey = '$plainText:$searchText'; + if (_highlightedSpansCache.containsKey(cacheKey)) { + return _highlightedSpansCache[cacheKey]!; + } + + final List spans = []; + + // Extraer palabras clave de la búsqueda y filtrar las muy cortas + final List keywords = searchText + .trim() + .toLowerCase() + .split(RegExp(r'\s+')) + .where((word) => word.isNotEmpty && word.length >= 3) + .toList(); + + if (keywords.isEmpty) { + return [TextSpan(text: plainText)]; + } + + // Texto en minúsculas para comparaciones + final String lowerText = plainText.toLowerCase(); + + // Crear una lista de todas las coincidencias + final List<_Match> allMatches = []; + + // Usar un enfoque más eficiente para encontrar coincidencias + for (final keyword in keywords) { + // Para palabras de más de 4 letras, considerar la raíz como las primeras n-1 letras + String baseWord = keyword; + if (baseWord.length > 4) { + baseWord = baseWord.substring(0, baseWord.length - 1); + } + + // Buscar la palabra base en el texto + int startIndex = 0; + while (true) { + final int index = lowerText.indexOf(baseWord, startIndex); + if (index == -1) break; + + // Encontrar el final de la palabra + int endIndex = index + baseWord.length; + while (endIndex < lowerText.length && + _isWordCharacter(lowerText[endIndex])) { + endIndex++; + } + + // Añadir la coincidencia + allMatches.add(_Match( + start: index, + end: endIndex, + text: plainText.substring(index, endIndex), + )); + + // Continuar desde el final de esta coincidencia + startIndex = endIndex; + } + } + + // Si no hay coincidencias, devolver el texto original + if (allMatches.isEmpty) { + return [TextSpan(text: plainText)]; + } + + // Ordenar coincidencias por posición + allMatches.sort((a, b) => a.start.compareTo(b.start)); + + // Manejar coincidencias superpuestas + final List<_Match> mergedMatches = []; + if (allMatches.isNotEmpty) { + _Match current = allMatches.first; + for (int i = 1; i < allMatches.length; i++) { + final _Match next = allMatches[i]; + if (current.end >= next.start) { + // Las coincidencias se superponen, fusionarlas + current = _Match( + start: current.start, + end: math.max(current.end, next.end), + text: plainText.substring( + current.start, math.max(current.end, next.end)), + ); + } else { + // Sin superposición, agregar la actual al resultado y pasar a la siguiente + mergedMatches.add(current); + current = next; + } + } + mergedMatches.add(current); // Agregar la última coincidencia + } + + // Construir spans a partir de coincidencias fusionadas + int lastIndex = 0; + for (final match in mergedMatches) { + if (match.start > lastIndex) { + spans.add(TextSpan( + text: plainText.substring(lastIndex, match.start), + )); + } + + spans.add(TextSpan( + text: plainText.substring(match.start, match.end), + style: const TextStyle(backgroundColor: Color(0xFFfff930)), + )); + + lastIndex = match.end; + } + + // Agregar texto restante + if (lastIndex < plainText.length) { + spans.add(TextSpan( + text: plainText.substring(lastIndex), + )); + } + + // Guardar en caché para uso futuro + _highlightedSpansCache[cacheKey] = spans; + + return spans; + } + + // Verificar si un carácter es parte de una palabra + bool _isWordCharacter(String char) { + return RegExp(r'[a-zñáéíóúüA-ZÑÁÉÍÓÚÜ0-9]').hasMatch(char); + } + + // Widget optimizado para texto resaltado + Widget _buildHighlightedText(String text, String searchText, TextStyle style, + {int maxLines = 2}) { + return RichText( + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + text: TextSpan( + children: _getHighlightedSpans(text, searchText), + style: style, + ), + textScaler: TextScaler.linear(1.0), + ); + } + + // Widget optimizado para texto de ubicación + Widget _buildLocationText(Draft message) { + // Evitar cálculos costosos en cada reconstrucción + final String locationText = message.city.isNotEmpty + ? '${message.city}, ${_getCountryNameCached(message.country)}' + : _getCountryNameCached(message.country); + + return Text( + locationText, + style: const TextStyle(fontSize: 14, height: 1.2), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } + + // Versión en caché de _getCountryName + String _getCountryNameCached(String countryCode) { + if (countryCode.isEmpty) { + return 'N/A'; + } + + if (_countryNameCache.containsKey(countryCode)) { + return _countryNameCache[countryCode]!; + } + + try { + final name = CountryCodes.detailsFromAlpha2(countryCode).name.toString(); + _countryNameCache[countryCode] = name; + return name; + } catch (e) { + _countryNameCache[countryCode] = countryCode; + return countryCode; + } + } + + Widget _buildDefaultItem(Draft message) { + // Usar un widget de construcción diferida para mejorar el rendimiento + return RepaintBoundary( + child: Card( + key: ValueKey( + 'search_result_${message.id}_${message.date.millisecondsSinceEpoch}'), + color: const Color(0xFFdfe6ce), + margin: const EdgeInsets.only(bottom: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: InkWell( + onTap: () => nav.pushScreenWithoutNavBar( + context, + TextViewer( + data: message, + searchTerm: message.body != null + ? _getMatchedText(message.body!, _currentQuery) + : _currentQuery)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + width: 100, + height: 80, + decoration: const BoxDecoration( + color: Color(0xFFf1f5eb), + ), + child: _buildThumbnail(message.thumbnail), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildItemTitle(message), + const SizedBox(height: 5), + Text( + utils.formatDate( + message.date, context.locale.toString()), + style: const TextStyle(fontSize: 14, height: 1), + ), + const SizedBox(height: 5), + _buildLocationText(message), + const SizedBox(height: 5), + Text( + "${plural('activity', 1)} ${message.activity}", + style: const TextStyle(fontSize: 14, height: 1), + ), + ], + ), + ), + ], + ), + if (message.body != null && message.body!.isNotEmpty) + _buildSnippet(message.body!, _currentQuery), + ], + ), + ), + ), + ), + ); + } + + // Widget optimizado para el título del item + Widget _buildItemTitle(Draft message) { + if (message.title.isEmpty) { + return Text( + utils.formatDate(message.date, context.locale.toString()), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + height: 1.2, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ); + } + + return _buildHighlightedText( + message.title, + _currentQuery, + const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + height: 1.2, + color: Colors.black, + fontFamily: 'Outfit', + ), + maxLines: 2, + ); + } + + // Widget optimizado para el snippet + Widget _buildSnippet(String snippet, String query) { + return Padding( + padding: const EdgeInsets.only(top: 16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + ), + child: _buildHighlightedText( + snippet, + query, + const TextStyle( + fontSize: 14, + color: Colors.black87, + height: 1.5, + fontFamily: 'Outfit', + ), + maxLines: 3, + ), + ), + ); + } + + // Widget para el encabezado de búsqueda + Widget _buildSearchHeader() { + Widget loadingIndicator(String text) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFF6b8e23).withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFF6b8e23).withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + return Transform.scale( + scale: _pulseAnimation.value, + child: child, + ); + }, + child: const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Color(0xFF6b8e23)), + ), + ), + ), + const SizedBox(width: 8), + Text( + text, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF6b8e23), + ), + ), + ], + ), + ); + } + + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + '${'searching'.tr()}: "$_currentQuery"', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + // Mostrar el contador de resultados tan pronto como tengamos los datos + if (!dataLoading && totalResults > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF6b8e23).withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + "$totalResults ${'results'.plural(totalResults)}", + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF6b8e23), + ), + ), + ), + ], + ), + ], + ), + ), + if (dataLoading) loadingIndicator('searching_in_progress'.tr()), + ], + ); + } + + @override + Widget build(BuildContext context) { + return BaseScreen( + title: widget.title, + showSearchBar: true, + showSettingsButton: true, + searchController: _searchController, + onSearchChanged: _onSearch, + onSearchSubmitted: (query) => _onSearch(query), + searchHintText: widget.hintText, + returnButton: true, + child: Stack( + children: [ + // Logo cuando no hay búsqueda activa + if (!_isSearching || _currentQuery.isEmpty) + Positioned( + bottom: 80.0, + left: 0, + right: 0, + child: Center( + child: Image.asset( + 'assets/image/logo.png', + width: 200, + ), + ), + ), + + // Resultados de búsqueda + if (_isSearching) + Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSearchHeader(), + const SizedBox(height: 16), + Expanded( + child: Expanded( + child: PagedListView.separated( + pagingController: _pagingController, + padding: const EdgeInsets.only(bottom: 24, top: 8), + separatorBuilder: (context, index) => + const SizedBox(height: 12), + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, message, index) => + widget.customItemBuilder != null + ? widget.customItemBuilder!( + message, _currentQuery) + : _buildDefaultItem(message), + firstPageProgressIndicatorBuilder: (_) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + return Transform.scale( + scale: _pulseAnimation.value, + child: child, + ); + }, + child: const CircularProgressIndicator( + color: Color(0XFF6b8e23), + strokeWidth: 3, + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFF6b8e23) + .withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFF6b8e23) + .withOpacity(0.3), + width: 1, + ), + ), + child: Text( + 'searching_in_progress'.tr(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF6b8e23), + ), + ), + ), + // Mostrar el contador de resultados tan pronto como tengamos los datos + if (!dataLoading && totalResults > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFF6b8e23) + .withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFF6b8e23) + .withOpacity(0.3), + width: 1, + ), + ), + child: Text( + "$totalResults ${'results'.plural(totalResults)} ${'found'.tr()}", + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF6b8e23), + ), + ), + ), + ), + ], + ), + ), + newPageProgressIndicatorBuilder: (_) => Container( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFF6b8e23) + .withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFF6b8e23) + .withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + return Transform.scale( + scale: _pulseAnimation.value, + child: child, + ); + }, + child: const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation( + Color(0xFF6b8e23)), + ), + ), + ), + const SizedBox(width: 10), + Text( + 'loading_more'.tr(), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF6b8e23), + ), + ), + ], + ), + ), + ], + ), + ), + noItemsFoundIndicatorBuilder: (_) => + widget.emptyStateWidget ?? + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFF6b8e23) + .withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.search_off_rounded, + size: 48, + color: Color(0XFF6b8e23), + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFF6b8e23) + .withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFF6b8e23) + .withOpacity(0.3), + width: 1, + ), + ), + child: Text( + 'no_results'.tr(), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF6b8e23), + ), + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 40), + child: Text( + 'try_different_search'.tr(), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + ), + ), + ), + ], + ), + ), + firstPageErrorIndicatorBuilder: (context) => + widget.errorWidget ?? + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 64, + color: Colors.red, + ), + const SizedBox(height: 16), + Text( + 'search_error'.tr(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () => + _pagingController.refresh(), + child: Text('retry'.tr()), + ), + ], + ), + ), + ), + // Configure to load the next page earlier + scrollDirection: Axis.vertical, + shrinkWrap: true, + addAutomaticKeepAlives: true, + addRepaintBoundaries: true, + // Add these properties to make pagination more responsive + addSemanticIndexes: false, + physics: const AlwaysScrollableScrollPhysics(), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + String _getMatchedText(String content, String searchQuery) { + if (searchQuery.isEmpty) return ''; + + // Convertir a minúsculas para búsqueda insensible a mayúsculas/minúsculas + final lowerContent = content.toLowerCase(); + final lowerQuery = searchQuery.toLowerCase(); + + // Buscar la coincidencia exacta + final int index = lowerContent.indexOf(lowerQuery); + if (index != -1) { + // Devolver el texto original que coincidió, no la versión en minúsculas + return content.substring(index, index + searchQuery.length); + } + + // Si no hay coincidencia exacta, buscar palabras individuales + final queryWords = lowerQuery + .split(RegExp(r'\s+')) + .where((word) => word.length > 2) + .toList(); + + for (final word in queryWords) { + final wordIndex = lowerContent.indexOf(word); + if (wordIndex != -1) { + // Devolver la primera palabra que coincida + return content.substring(wordIndex, wordIndex + word.length); + } + } + + return searchQuery; // Fallback al término de búsqueda original + } + + void _showNotification() { + NotificationService().showSearchNotification( + title: 'search'.tr(), + body: 'empty_results'.tr(), + ); + } +} diff --git a/lib/screens/home.dart b/lib/screens/home.dart new file mode 100644 index 0000000..8e24ed6 --- /dev/null +++ b/lib/screens/home.dart @@ -0,0 +1,1546 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:country_codes/country_codes.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:search_engine/database.dart'; +import 'package:search_engine/screens/config.dart'; +import 'package:search_engine/screens/content.dart'; +import 'package:search_engine/screens/generic_search.dart'; +import 'package:search_engine/widgets/base.dart'; +import 'package:search_engine/widgets/navigation_bar.dart'; +import 'package:dio/dio.dart'; +import 'package:search_engine/screens/pdf.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:search_engine/utils.dart' as utils; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart' + as nav; +import 'package:flutter/services.dart'; +import 'package:search_engine/services/config_service.dart'; + +// Extensión para capitalizar strings +extension StringExtension on String { + String capitalize() { + if (isEmpty) return this; + return '${this[0].toUpperCase()}${substring(1)}'; + } +} + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + // ignore: library_private_types_in_public_api + _HomePageState createState() => _HomePageState(); +} + +class _HomePageState extends State { + late Directory appDirectory; + late Future _databaseFuture; + List allMessages = []; // Unified list for all messages + Timer? _debounce; + final TextEditingController _searchController = TextEditingController(); + final _baseUrl = dotenv.env['BASE_URL']; + final _token = dotenv.env['TOKEN']; + late String locale; + List favorites = []; + final bool _showSearchOverlay = false; + final _searchOverlayController = TextEditingController(); + Timer? _searchDebounce; + List _searchResults = []; + bool _isSearching = false; + final _searchFocusNode = FocusNode(); + + // Estados de carga + bool isLoadingMessages = false; + bool isLoadingFavorites = true; + + // Variables para el filtro por año y mes + List _availableYears = []; + List _availableMonths = []; + String? _selectedYear; + String? _selectedMonth; + bool _isFiltered = false; + bool isLoadingYears = false; + bool isLoadingMonths = false; + + // Controlador para la carga progresiva + final ScrollController _messagesScrollController = ScrollController(); + + // Caché de miniaturas + final Map _thumbnailCache = {}; + + String? _error; + + @override + void initState() { + super.initState(); + _databaseFuture = Future.value(AppDatabase()); + _initAppDirectory(); + _loadLocale().then((_) { + _loadData(); + _loadAvailableYears(); + }); + } + + @override + void dispose() { + _messagesScrollController.dispose(); + _debounce?.cancel(); + _searchDebounce?.cancel(); + super.dispose(); + } + + Future _loadData() async { + try { + setState(() { + isLoadingMessages = true; + _error = null; + }); + + final token = RootIsolateToken.instance; + if (token == null) { + throw Exception('RootIsolateToken is not initialized'); + } + + final messagesResult = await compute( + _getAllMessagesIsolate, + [locale, token, 15], // Limitar a 15 mensajes inicialmente + ); + + if (mounted) { + setState(() { + allMessages = messagesResult; + isLoadingMessages = false; + }); + } + } catch (e) { + if (kDebugMode) { + print('Error loading messages: $e'); + } + if (mounted) { + setState(() { + _error = e.toString(); + isLoadingMessages = false; + }); + } + } + } + + Future _loadAllMessages() async { + try { + setState(() { + isLoadingMessages = true; + }); + + final token = RootIsolateToken.instance; + if (token == null) { + throw Exception('RootIsolateToken is not initialized'); + } + + final messagesResult = await compute( + _getAllMessagesIsolate, + [locale, token, 15], // Limitar a 15 mensajes inicialmente + ); + + if (mounted) { + setState(() { + allMessages = messagesResult; + isLoadingMessages = false; + }); + } + } catch (e) { + if (kDebugMode) { + print('Error fetching all messages: $e'); + } + if (mounted) { + setState(() { + allMessages = []; + isLoadingMessages = false; + }); + } + } + } + + Future _loadLocale() async { + final newLocale = await ConfigService.getLocale(); + if (mounted) { + setState(() { + locale = newLocale; + }); + } + } + + // Método para cargar los años disponibles + Future _loadAvailableYears() async { + setState(() { + isLoadingYears = true; + _availableYears = []; + }); + + try { + final database = await _databaseFuture; + final years = await database.getAvailableYears(); + setState(() { + _availableYears = years; + isLoadingYears = false; + }); + } catch (e) { + setState(() { + _availableYears = []; + isLoadingYears = false; + }); + } + } + + // Método para cargar los meses disponibles para un año seleccionado + Future _loadAvailableMonths(String year) async { + setState(() { + isLoadingMonths = true; + _availableMonths = []; + }); + + try { + final database = await _databaseFuture; + final months = await database.getMonths(year); + setState(() { + _availableMonths = months; + isLoadingMonths = false; + }); + } catch (e) { + setState(() { + _availableMonths = []; + isLoadingMonths = false; + }); + } + } + + // Funciones estáticas para los isolates + static Future> _getAllMessagesIsolate( + List params) async { + try { + final locale = params[0] as String; + final token = params[1] as RootIsolateToken; + final limit = params.length > 2 ? params[2] as int : null; + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + + final database = AppDatabase(); + return database.getAllMessages(locale, limit); + } catch (e) { + if (kDebugMode) { + print('Error in _getAllMessagesIsolate: $e'); + } + return []; + } + } + + static Future> _getFilteredMessagesIsolate( + List params) async { + try { + final year = params[0] as String; + final month = params[1] as String; + final locale = params[2] as String; + final token = params[3] as RootIsolateToken; + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + + final database = AppDatabase(); + return database.getFilteredMessages(year, month, locale); + } catch (e) { + if (kDebugMode) { + print('Error in _getFilteredMessagesIsolate: $e'); + } + return []; + } + } + + static Future> _searchMessagesIsolate( + List params) async { + try { + final query = params[0] as String; + final locale = params[1] as String; + final token = params[2] as RootIsolateToken; + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + + final database = AppDatabase(); + return database.searchMessages(query, locale); + } catch (e) { + if (kDebugMode) { + print('Error in _searchMessagesIsolate: $e'); + } + return []; + } + } + + // Método para aplicar filtros y cargar datos filtrados + Future _applyFilters() async { + if (_selectedYear != null && _selectedMonth != null) { + setState(() { + _isFiltered = true; + isLoadingMessages = true; + }); + + try { + final token = RootIsolateToken.instance; + if (token == null) { + throw Exception('RootIsolateToken is not initialized'); + } + + final filteredMessages = await compute( + _getFilteredMessagesIsolate, + [_selectedYear!, _selectedMonth!, locale, token], + ); + + if (mounted) { + setState(() { + allMessages = filteredMessages; + isLoadingMessages = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + isLoadingMessages = false; + }); + } + } + } + } + + // Método para limpiar filtros + void _clearFilters() { + setState(() { + _selectedYear = null; + _selectedMonth = null; + _isFiltered = false; + }); + + _loadAllMessages(); + } + + void _onSearch(String value) { + if (_debounce?.isActive ?? false) _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 500), () async { + if (value.isNotEmpty) { + nav.pushScreenWithoutNavBar( + context, + GenericSearchPage( + searchTerm: value, + title: 'search'.tr(), + hintText: 'search_placeholder'.tr(), + searchFunction: (query, pageKey, offset) async { + final token = RootIsolateToken.instance; + if (token == null) { + throw Exception('RootIsolateToken is not initialized'); + } + return await compute( + _searchMessagesIsolate, [query, locale, token]); + }, + ), + ); + } + }); + } + + Future _initAppDirectory() async { + appDirectory = await getApplicationDocumentsDirectory(); + } + + // Cargar datos de forma progresiva + Future _loadFavorites() async { + try { + final fav = + await _databaseFuture.then((database) => database.getFavorites()); + if (mounted) { + setState(() { + favorites = fav; + isLoadingFavorites = false; + }); + } + } catch (e) { + if (kDebugMode) { + print('Error fetching favorites: $e'); + } + if (mounted) { + setState(() { + favorites = []; + isLoadingFavorites = false; + }); + } + } + } + + Future _getThumbnail(String directoryPath, String fileId) async { + // Verificar si la miniatura ya está en caché + final cacheKey = '$directoryPath/$fileId'; + if (_thumbnailCache.containsKey(cacheKey)) { + return _thumbnailCache[cacheKey]; + } + + if (fileId.isEmpty) { + _thumbnailCache[cacheKey] = null; + return null; + } + + try { + final dir = Directory(directoryPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + final String thumbnailPath = '$directoryPath/$fileId+SD.jpg'; + final thumbnailFile = File(thumbnailPath); + + // Verificar si el archivo ya existe localmente + if (await thumbnailFile.exists()) { + _thumbnailCache[cacheKey] = thumbnailFile; + return thumbnailFile; + } + + // Descargar la miniatura si no existe + final String url = + '$_baseUrl/assets/$fileId?access_token=$_token&width=320&height=180&quality=50&fit=cover&format=jpg'; + + await Dio().download(url, thumbnailPath); + + final file = File(thumbnailPath); + _thumbnailCache[cacheKey] = file; + return file; + } catch (e) { + if (kDebugMode) { + print('Error downloading thumbnail: $e'); + } + _thumbnailCache[cacheKey] = null; + return null; + } + } + + Widget _buildThumbnail(String thumbnailId) { + return FutureBuilder( + future: _getThumbnail( + '${appDirectory.path}/LGCC_Search/$locale/thumbnails/', thumbnailId), + builder: (context, snapshot) { + return Skeletonizer( + enableSwitchAnimation: true, + enabled: snapshot.connectionState != ConnectionState.done, + effect: const ShimmerEffect( + baseColor: Color(0xFFf1f5eb), + highlightColor: Colors.white30, + duration: Duration(milliseconds: 1000), + ), + child: snapshot.hasData && snapshot.data != null + ? Image.file( + snapshot.data!, + height: double.infinity, + width: double.infinity, + fit: BoxFit.cover, + cacheHeight: 180, + cacheWidth: 320, + ) + : Image.asset( + 'assets/image/default_thumbnail.jpg', + height: double.infinity, + width: double.infinity, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + cacheHeight: 180, + cacheWidth: 320, + ), + ); + }, + ); + } + + Future _performSearch(String query) async { + try { + final results = await _databaseFuture + .then((database) => database.searchMessages(query, locale)); + setState(() { + _searchResults = results; + _isSearching = false; + }); + } catch (e) { + if (kDebugMode) { + print('Error performing search: $e'); + } + setState(() { + _searchResults = []; + _isSearching = false; + }); + } + } + + Widget _buildSearchResultsOverlay() { + if (!_showSearchOverlay) return const SizedBox.shrink(); + + return Positioned( + top: 80, // Adjust this value based on your header height + left: 24, + right: 24, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(12), + child: Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isSearching) + const Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(), + ) + else if (_searchResults.isEmpty) + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.search_off, + size: 64, + color: Color(0XFF6b8e23), + ), + const SizedBox(height: 16), + Text( + 'no_results'.tr(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ) + else + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: _searchResults.length, + itemBuilder: (context, index) { + final message = _searchResults[index]; + return _buildListCard(message); + }, + ), + ), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + BaseScreen( + title: 'home'.tr(), + showSearchBar: true, + showSettingsButton: true, + searchController: _searchController, + onSearchChanged: _onSearch, + searchHintText: 'search_placeholder'.tr(), + child: RefreshIndicator.adaptive( + onRefresh: _refreshData, + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + if (_error != null) + Container( + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.red[100], + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.error_outline, + color: Colors.red[700]), + const SizedBox(width: 8), + Expanded( + child: Text( + _error!, + style: TextStyle(color: Colors.red[700]), + ), + ), + ], + ), + ), + _buildAllMessagesSection(), + ], + ), + ), + ); + }, + ), + ), + ), + _buildSearchResultsOverlay(), + ], + ); + } + + Widget _buildDrawer(BuildContext context) { + return Drawer( + child: ListView( + padding: EdgeInsets.zero, + children: [ + DrawerHeader( + decoration: const BoxDecoration( + color: Color(0xFF6b8e23), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const CircleAvatar( + radius: 40, + backgroundColor: Colors.white, + child: Icon( + Icons.menu_book, + size: 40, + color: Color(0xFF6b8e23), + ), + ), + const SizedBox(height: 10), + Text( + 'title'.tr(), + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ListTile( + leading: const Icon(Icons.home), + title: Text('title'.tr()), + onTap: () { + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.search), + title: Text('search'.tr()), + onTap: () { + Navigator.pop(context); + GlobalNavigator.navigateToIndex(context, 0); + }, + ), + ListTile( + leading: const Icon(Icons.calendar_today), + title: Text('calendar'.tr()), + onTap: () { + Navigator.pop(context); + GlobalNavigator.navigateToIndex(context, 2); + }, + ), + ListTile( + leading: const Icon(Icons.menu_book), + title: Text('library'.tr()), + onTap: () { + Navigator.pop(context); + GlobalNavigator.navigateToIndex(context, 2); + }, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.settings), + title: Text('settings'.tr()), + onTap: () { + Navigator.pop(context); + nav.pushScreenWithoutNavBar(context, const ConfigView()); + }, + ), + ], + ), + ); + } + + Widget _buildAllMessagesSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _isFiltered + ? '${DateFormat('MMMM', locale).format(DateTime(0, int.parse(_selectedMonth!))).capitalize()} $_selectedYear' + : 'last_activities'.tr(), + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Row( + children: [ + if (_isFiltered) + TextButton.icon( + icon: const Icon(Icons.clear, color: Colors.red), + label: Text( + 'clear_filters'.tr(), + style: const TextStyle(color: Colors.red), + ), + onPressed: _clearFilters, + ), + ElevatedButton( + onPressed: _showFilterDialog, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6b8e23), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.filter_list, + size: 18, color: Colors.white), + const SizedBox(width: 4), + Text( + 'filter'.tr(), + style: const TextStyle(fontSize: 13), + ), + ], + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + if (isLoadingMessages) + _buildSkeletonLoaders() + else if (allMessages.isEmpty) + Container( + height: 260, + alignment: Alignment.center, + child: Text(_isFiltered + ? 'no_activities_for_period'.tr() + : 'no_recent_activities'.tr()), + ) + else + _buildUnifiedMessagesList(), + ], + ); + } + + Widget _buildSkeletonLoaders() { + // Determinar si la pantalla es lo suficientemente grande para mostrar grid + final screenWidth = MediaQuery.of(context).size.width; + final screenHeight = MediaQuery.of(context).size.height; + final isWideScreen = screenWidth > 600 || screenWidth > screenHeight; + + // Calcular el número de columnas basado en el ancho de pantalla + int crossAxisCount = 2; + if (screenWidth > screenHeight) { + // En modo horizontal + if (screenWidth > 900) { + crossAxisCount = 4; + } else if (screenWidth > 600) { + crossAxisCount = 3; + } else { + crossAxisCount = 2; + } + } else { + // En modo vertical + if (screenWidth > 600) { + crossAxisCount = 2; + } else { + crossAxisCount = 1; + } + } + + if (isWideScreen) { + // Skeleton loaders para grid + return OrientationBuilder(builder: (context, orientation) { + return GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + childAspectRatio: 0.75, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemCount: 6, + itemBuilder: (context, index) { + return _buildGridSkeletonLoader(); + }, + ); + }); + } else { + // Skeleton loaders para lista + return ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemCount: 6, + itemBuilder: (context, index) { + return _buildListSkeletonLoader(); + }, + ); + } + } + + Widget _buildGridSkeletonLoader() { + return Card( + elevation: 3, + shadowColor: Colors.black26, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFecefe2), + Color(0xFFdfe7d0), + ], + begin: Alignment.topRight, + end: Alignment.bottomLeft, + ), + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + child: Skeletonizer( + enabled: true, + effect: const ShimmerEffect( + baseColor: Color(0xFFdfe7d0), + highlightColor: Colors.white70, + duration: Duration(milliseconds: 1000), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Thumbnail skeleton + ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + child: AspectRatio( + aspectRatio: 16 / 9, + child: Container( + color: Colors.grey[300], + ), + ), + ), + + // Content skeleton + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 16, + width: double.infinity, + color: Colors.grey[300], + ), + const SizedBox(height: 4), + Container( + height: 14, + width: 100, + color: Colors.grey[300], + ), + const SizedBox(height: 4), + Container( + height: 12, + width: 120, + color: Colors.grey[300], + ), + const SizedBox(height: 4), + Container( + height: 12, + width: 80, + color: Colors.grey[300], + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildListSkeletonLoader() { + return Card( + elevation: 3, + shadowColor: Colors.black26, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFecefe2), + Color(0xFFdfe7d0), + ], + begin: Alignment.topRight, + end: Alignment.bottomLeft, + ), + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + child: Skeletonizer( + enabled: true, + effect: const ShimmerEffect( + baseColor: Color(0xFFdfe7d0), + highlightColor: Colors.white70, + duration: Duration(milliseconds: 1000), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Thumbnail skeleton - ahora más cuadrado + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + width: 130, + height: 130, + color: Colors.grey[300], + ), + ), + + const SizedBox(width: 16), + + // Content skeleton - con más espacio vertical + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 20, + width: double.infinity, + color: Colors.grey[300], + ), + const SizedBox(height: 8), + Container( + height: 16, + width: 120, + color: Colors.grey[300], + ), + const SizedBox(height: 8), + Container( + height: 14, + width: 150, + color: Colors.grey[300], + ), + const SizedBox(height: 8), + Container( + height: 14, + width: 100, + color: Colors.grey[300], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildUnifiedMessagesList() { + // Determinar si la pantalla es lo suficientemente grande para mostrar grid + final screenWidth = MediaQuery.of(context).size.width; + final screenHeight = MediaQuery.of(context).size.height; + final isWideScreen = screenWidth > 600 || screenWidth > screenHeight; + + // Calcular el número de columnas basado en el ancho de pantalla + int crossAxisCount = 2; + if (screenWidth > screenHeight) { + // En modo horizontal + if (screenWidth > 900) { + crossAxisCount = 4; + } else if (screenWidth > 600) { + crossAxisCount = 3; + } else { + crossAxisCount = 2; + } + } else { + // En modo vertical + if (screenWidth > 600) { + crossAxisCount = 2; + } else { + crossAxisCount = 1; + } + } + + if (isWideScreen) { + // Mostrar grid en pantallas anchas o en orientación horizontal + return OrientationBuilder(builder: (context, orientation) { + final isLandscape = orientation == Orientation.landscape; + return GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + childAspectRatio: isLandscape ? 1.1 : 0.85, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemCount: allMessages.length, + itemBuilder: (context, index) { + final message = allMessages[index]; + return _buildGridCard(message); + }, + ); + }); + } else { + // Mostrar lista en pantallas estrechas + return ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemCount: allMessages.length, + itemBuilder: (context, index) { + final message = allMessages[index]; + return _buildListCard(message); + }, + ); + } + } + + Widget _buildGridCard(Draft message) { + return Card( + elevation: 3, + shadowColor: Colors.black26, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFecefe2), + Color(0xFFdfe7d0), + ], + begin: Alignment.topRight, + end: Alignment.bottomLeft, + ), + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => + nav.pushScreenWithoutNavBar(context, TextViewer(data: message)), + borderRadius: BorderRadius.circular(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + child: Stack( + fit: StackFit.expand, + children: [ + SizedBox( + width: double.infinity, + height: double.infinity, + child: _buildThumbnail(message.thumbnail), + ), + _buildMessageIndicators(message), + ], + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + message.title.isNotEmpty + ? message.title + : utils.formatDate(message.date, locale), + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + utils.formatDate(message.date, locale), + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + height: 1.2, + ), + ), + if (message.city.isNotEmpty || + message.country.isNotEmpty) + Text( + message.city.isNotEmpty + ? '${message.city}, ${_getCountryName(message.country)}' + : _getCountryName(message.country), + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (message.activity > 0) + Text( + '${plural('activity', 1)} ${message.activity}', + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + height: 1.2, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildListCard(Draft message) { + return Card( + elevation: 3, + shadowColor: Colors.black26, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFecefe2), + Color(0xFFdfe7d0), + ], + begin: Alignment.topRight, + end: Alignment.bottomLeft, + ), + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => + nav.pushScreenWithoutNavBar(context, TextViewer(data: message)), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 130, + height: 130, + child: Stack( + fit: StackFit.expand, + children: [ + _buildThumbnail(message.thumbnail), + _buildMessageIndicators(message, isListView: true), + ], + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + message.title.isNotEmpty + ? message.title + : utils.formatDate(message.date, locale), + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 20, + height: 1.2, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + utils.formatDate(message.date, locale), + style: const TextStyle( + color: Colors.grey, + fontSize: 14, + height: 1.2, + ), + ), + if (message.city.isNotEmpty || + message.country.isNotEmpty) + Text( + message.city.isNotEmpty + ? '${message.city}, ${_getCountryName(message.country)}' + : _getCountryName(message.country), + style: const TextStyle( + color: Colors.grey, + fontSize: 14, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (message.activity > 0) + Text( + '${plural('activity', 1)} ${message.activity}', + style: const TextStyle( + fontSize: 14, + color: Colors.grey, + height: 1.2, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + // Método para construir los indicadores (draft, PDF, favorito) + Widget _buildMessageIndicators(Draft message, {bool isListView = false}) { + return Stack( + fit: StackFit.expand, + children: [ + // Indicadores de Draft y PDF en la esquina superior izquierda + Positioned( + top: 4, + left: 4, + child: Row( + children: [ + // Draft indicator + if (message.draft == 1) + Container( + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.8), + borderRadius: BorderRadius.circular(4), + ), + padding: EdgeInsets.symmetric( + horizontal: isListView ? 8 : 6, + vertical: isListView ? 4 : 2), + child: Text( + 'draft'.tr(), + style: TextStyle( + color: Colors.white, + fontSize: isListView ? 12 : 12, + fontWeight: FontWeight.bold, + ), + ), + ), + // Espacio entre indicadores si ambos están presentes + if (message.draft == 1 && + message.pdf != null && + message.pdf!.isNotEmpty) + const SizedBox(width: 6), + // PDF indicator + if (message.pdf != null && message.pdf!.isNotEmpty) + InkWell( + onTap: () { + nav.pushScreenWithoutNavBar( + context, + FilePdf(pdf: message.pdf!, title: message.title), + ); + }, + child: Container( + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + padding: EdgeInsets.all(isListView ? 4 : 4), + child: Icon( + Icons.picture_as_pdf, + color: Colors.white, + size: isListView ? 18 : 16, + ), + ), + ), + ], + ), + ), + // Favorite indicator (se mantiene en la esquina inferior derecha) + if (favorites.contains(message.id)) + Positioned( + bottom: 4, + right: 4, + child: Container( + decoration: BoxDecoration( + color: Colors.green.withOpacity(0.8), + borderRadius: BorderRadius.circular(4), + ), + padding: EdgeInsets.all(isListView ? 4 : 4), + child: Icon( + Icons.bookmark, + color: Colors.white, + size: isListView ? 18 : 16, + ), + ), + ), + ], + ); + } + + // Método para mostrar el diálogo de filtro + void _showFilterDialog() { + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'filter'.tr(), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF6b8e23), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + content: Container( + width: double.maxFinite, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.6, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'select_year'.tr(), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + if (isLoadingYears) + const Center(child: CircularProgressIndicator()) + else + SizedBox( + height: 50, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _availableYears.length, + itemBuilder: (context, index) { + final year = _availableYears[index]; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(year), + selected: _selectedYear == year, + onSelected: (selected) { + setDialogState(() { + _selectedYear = selected ? year : null; + _selectedMonth = null; + if (selected) { + _loadAvailableMonths(year); + } else { + _availableMonths = []; + } + }); + }, + backgroundColor: const Color(0xFFecefe2), + selectedColor: const Color(0xFF6b8e23), + labelStyle: TextStyle( + color: _selectedYear == year + ? Colors.white + : Colors.black, + ), + ), + ); + }, + ), + ), + const SizedBox(height: 24), + Text( + 'select_month'.tr(), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + if (_selectedYear == null) + Center( + child: Text( + 'select_year_first'.tr(), + style: TextStyle(color: Colors.grey), + ), + ) + else if (isLoadingMonths) + const Center(child: CircularProgressIndicator()) + else if (_availableMonths.isEmpty) + Center( + child: Text( + 'no_months_available'.tr(), + style: TextStyle(color: Colors.grey), + ), + ) + else + SizedBox( + height: 50, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _availableMonths.length, + itemBuilder: (context, index) { + final month = _availableMonths[index]; + final monthName = DateFormat('MMMM', locale) + .format(DateTime(0, int.parse(month))) + .capitalize(); + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(monthName), + selected: _selectedMonth == month, + onSelected: (selected) { + setDialogState(() { + _selectedMonth = selected ? month : null; + }); + }, + backgroundColor: const Color(0xFFecefe2), + selectedColor: const Color(0xFF6b8e23), + labelStyle: TextStyle( + color: _selectedMonth == month + ? Colors.white + : Colors.black, + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + setDialogState(() { + _selectedYear = null; + _selectedMonth = null; + _availableMonths = []; + }); + }, + child: Text( + 'clear'.tr(), + style: const TextStyle(color: Colors.grey), + ), + ), + ElevatedButton( + onPressed: _selectedYear != null && _selectedMonth != null + ? () { + Navigator.pop(context); + _applyFilters(); + } + : null, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6b8e23), + foregroundColor: Colors.white, + ), + child: Text('apply'.tr()), + ), + ], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + backgroundColor: Colors.white, + ); + }, + ), + ); + } + + // Método para refrescar todos los datos + Future _refreshData() async { + setState(() { + isLoadingMessages = true; + isLoadingFavorites = true; + }); + + if (_isFiltered && _selectedYear != null && _selectedMonth != null) { + await _applyFilters(); + } else { + await Future.wait([ + _loadFavorites(), + _loadAllMessages(), + ]); + } + + // Limpiar caché de miniaturas al refrescar + _thumbnailCache.clear(); + } + + String _getCountryName(String countryCode) { + if (countryCode.isEmpty) { + return 'N/A'; + } + + try { + return CountryCodes.detailsFromAlpha2(countryCode).name.toString(); + } catch (e) { + // Si no se encuentra el código de país, devolver el código como está + return countryCode; + } + } +} diff --git a/lib/screens/landing.dart b/lib/screens/landing.dart new file mode 100644 index 0000000..a451f6e --- /dev/null +++ b/lib/screens/landing.dart @@ -0,0 +1,1207 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:convert'; + +import 'package:country_codes/country_codes.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:http/http.dart' as http; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:search_engine/database.dart'; +import 'package:search_engine/widgets/navigation_bar.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:search_engine/services/config_service.dart'; +import 'package:search_engine/controllers/notification_controller.dart'; +import 'package:search_engine/services/mimir_service.dart'; +import 'package:search_engine/services/live_activities_service.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class LandingPage extends StatefulWidget { + final bool? changeLocale; + + const LandingPage({super.key, this.changeLocale}); + + @override + // ignore: library_private_types_in_public_api + _LandingPageState createState() => _LandingPageState(); +} + +class _LandingPageState extends State { + double _updateProgress = 0.0; + late Future _databaseFuture; + String _updateInfo = 'downloading_data'.tr(); + final _baseUrl = dotenv.env['BASE_URL']; + final _token = dotenv.env['TOKEN']; + final _version = dotenv.env['VERSION'] ?? '1.0'; + // Tamaño del lote para inserción en la base de datos + static const int _batchSize = 50; + final _mimirService = MimirService(); + final _liveActivitiesService = LiveActivitiesService(); + String? _syncActivityId; + + @override + void initState() { + super.initState(); + _databaseFuture = Future.value(AppDatabase()); + _mimirService.initialize(); + // Initialize Live Activities first, then continue with fetchData + _initLiveActivities().then((_) { + fetchData(); + }); + + // Initialize notifications + NotificationController.initialize().then((_) { + NotificationController.requestPermissions(); + }); + } + + @override + void dispose() { + _databaseFuture.then((database) => database.close()); + if (_syncActivityId != null) { + _liveActivitiesService.endSearchActivity(_syncActivityId!); + } + super.dispose(); + } + + void _goHome() { + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const GlobalNavigator())); + } + } + + Future fetchData() async { + if (kDebugMode) print('🔄 Iniciando fetchData'); + + final database = AppDatabase(); + try { + // Check if SharedPreferences is working correctly + if (kDebugMode) { + try { + final prefs = await SharedPreferences.getInstance(); + final prefsKeys = prefs.getKeys(); + print('🔑 SharedPreferences available keys: $prefsKeys'); + } catch (e) { + print('⚠️ Cannot access SharedPreferences: $e'); + } + } + + final locale = await getLocale(); + if (kDebugMode) print('🌍 Locale obtenido: $locale'); + + var date = await getDate(); + if (kDebugMode) { + print('📅 Fecha última sincronización: $date'); + try { + final parsedDate = DateTime.parse(date); + final now = DateTime.now(); + if (parsedDate.isAfter(now)) { + print('⚠️ WARNING: Last sync date is in the future!'); + print('📅 Future date: $parsedDate, Current date: $now'); + } + } catch (e) { + print('⚠️ Error parsing date: $e'); + } + } + + setState(() { + _updateInfo = 'connecting_to_server'.tr(); + _updateProgress = 0.0; + }); + + // Create search activity only if on iOS and LiveActivities were initialized properly + if (Platform.isIOS) { + _syncActivityId = await _createLiveActivity( + 'sync_title'.tr(), + 'sync_in_progress'.tr(), + '0', + ); + } + + // Verificar conectividad de forma más rápida + bool isConnected = false; + try { + final testUrl = Uri.parse('$_baseUrl/server/ping?access_token=$_token'); + final testResponse = await http.get(testUrl).timeout( + const Duration(seconds: 3), + onTimeout: () { + throw TimeoutException('No se pudo conectar al servidor'); + }, + ); + isConnected = testResponse.statusCode == 200; + } catch (e) { + if (kDebugMode) print('❌ Error de conectividad: $e'); + } + + if (!isConnected) { + setState(() { + _updateInfo = 'server_unreachable'.tr(); + }); + await Future.delayed(const Duration(seconds: 1)); + database.close(); + if (_syncActivityId != null) { + await _liveActivitiesService.endSearchActivity(_syncActivityId!); + } + _goHome(); + return; + } + + // Try to get the most recent item date from server to compare + final serverLatestDate = await _getServerLatestDate(locale); + if (kDebugMode) { + print('📅 Server latest date: $serverLatestDate'); + + // Parse dates for comparison + DateTime? localDate; + DateTime? serverDate; + try { + localDate = DateTime.parse(date); + if (serverLatestDate != null) { + serverDate = DateTime.parse(serverLatestDate); + } + } catch (e) { + print('⚠️ Date parsing error: $e'); + } + + // Compare dates if both are valid + if (localDate != null && serverDate != null) { + final difference = serverDate.difference(localDate).inDays; + print('📊 Days difference between local and server: $difference'); + if (difference <= 0) { + print('✅ Local date is up-to-date or ahead of server'); + } else { + print('⚠️ Server has $difference days of newer data'); + } + } + } + + // Obtener el total de elementos de forma más eficiente + final totalItems = await _getTotalItemsCount(locale, date); + if (totalItems == 0) { + if (kDebugMode) print('ℹ️ No hay elementos nuevos'); + setState(() { + _updateInfo = 'no_new_data'.tr(); + }); + await Future.delayed(const Duration(seconds: 1)); + database.close(); + if (_syncActivityId != null) { + await _liveActivitiesService.endSearchActivity(_syncActivityId!); + } + _goHome(); + return; + } + + if (_syncActivityId != null && + Platform.isIOS && + _liveActivitiesService.isSupported) { + try { + await _liveActivitiesService.updateSearchActivity( + activityId: _syncActivityId!, + title: 'sync_title'.tr(), + query: 'processing_data'.tr(), + count: totalItems.toString(), + ); + } catch (e) { + if (kDebugMode) { + print('Error updating Live Activity: $e'); + } + } + } + + // Inicializar contadores + int processedItems = 0; + int currentPage = 1; + bool hasMoreData = true; + final int pageSize = 50; + DateTime? lastProcessedDate; + // Variable para almacenar la fecha más reciente global + DateTime? mostRecentDateGlobal; + + setState(() { + _updateInfo = 'processing_data'.tr(); + }); + + while (hasMoreData) { + try { + if (kDebugMode) print('📑 Procesando página $currentPage'); + + // Ensure the date is properly formatted and encoded for URL + final encodedDate = Uri.encodeComponent(date); + if (kDebugMode) print('🔗 URL date parameter: $encodedDate'); + + final url = Uri.parse( + '$_baseUrl/items/activities_translations?sort=-activities_id.date&fields=*,activities_id.*,interventions.text&filter[languages_code][_eq]=$locale&filter[_or][0][activities_id][date_updated][_gt]=$encodedDate&filter[_or][1][activities_id][date_created][_gt]=$encodedDate&limit=$pageSize&page=$currentPage&access_token=$_token'); + + final response = + await http.get(url).timeout(const Duration(seconds: 10)); + if (response.statusCode != 200) { + throw HttpException( + 'Error en página $currentPage: ${response.statusCode}'); + } + + final jsonResponse = jsonDecode(utf8.decode(response.bodyBytes)); + final List data = jsonResponse['data'] ?? []; + + if (data.isEmpty) { + hasMoreData = false; + continue; + } + + // Procesar datos en un isolate + final processedData = await compute( + _processBatchItemsIsolate, + data, + ); + + final List batchMessages = processedData['messages']; + final List> mimirDocuments = + processedData['mimirDocuments']; + + // Guardar solo la información general en la base de datos + if (batchMessages.isNotEmpty) { + try { + await database.addMessages(batchMessages); + processedItems += batchMessages.length; + + // Guardar documentos en Mimir + if (mimirDocuments.isNotEmpty) { + await _mimirService.addDocuments(mimirDocuments); + } + + // Actualizar la fecha del último registro procesado + final firstItemDate = batchMessages.first.date; + if (firstItemDate != null) { + // Usar directamente la fecha del primer elemento procesado (el más reciente) + lastProcessedDate = firstItemDate; + + // Actualizar la fecha global más reciente si es necesario + if (mostRecentDateGlobal == null || + firstItemDate.isAfter(mostRecentDateGlobal!)) { + mostRecentDateGlobal = firstItemDate; + if (kDebugMode) { + print( + '📅 Nueva fecha global más reciente: ${DateFormat('yyyy-MM-ddTHH:mm:ss').format(mostRecentDateGlobal!)}'); + } + } + + if (kDebugMode) { + print( + '📅 Fecha del elemento más reciente procesado: ${DateFormat('yyyy-MM-ddTHH:mm:ss').format(lastProcessedDate)}'); + } + // Guardar fecha cada 5 lotes o al final + if (currentPage % 5 == 0 || !hasMoreData) { + // Guardar la fecha más reciente global, no la del último lote procesado + await setDate(mostRecentDateGlobal ?? lastProcessedDate); + } + } + + setState(() { + _updateProgress = processedItems / totalItems; + _updateInfo = 'processed_items' + .tr(args: ['$processedItems', '$totalItems']); + }); + + if (_syncActivityId != null && + Platform.isIOS && + _liveActivitiesService.isSupported) { + try { + await _liveActivitiesService.updateSearchActivity( + activityId: _syncActivityId!, + title: 'sync_title'.tr(), + query: 'processed_items' + .tr(args: ['$processedItems', '$totalItems']), + count: processedItems.toString(), + ); + } catch (e) { + if (kDebugMode) { + print('Error updating Live Activity: $e'); + } + } + } + } catch (e) { + if (kDebugMode) print('❌ Error guardando lote: $e'); + // Guardar fecha en caso de error + if (mostRecentDateGlobal != null) { + await setDate(mostRecentDateGlobal); + } else if (lastProcessedDate != null) { + await setDate(lastProcessedDate); + } + continue; + } + } + + currentPage++; + } catch (e) { + if (kDebugMode) print('❌ Error procesando página $currentPage: $e'); + // Guardar fecha en caso de error + if (mostRecentDateGlobal != null) { + await setDate(mostRecentDateGlobal); + } else if (lastProcessedDate != null) { + await setDate(lastProcessedDate); + } + currentPage++; + if (currentPage > (totalItems / pageSize) + 2) { + hasMoreData = false; + } + } + } + + if (processedItems > 0) { + // Asegurarnos de que estamos guardando la fecha más reciente global + final DateTime finalDate = + mostRecentDateGlobal ?? lastProcessedDate ?? DateTime.now(); + final String formattedFinalDate = + DateFormat('yyyy-MM-ddTHH:mm:ss').format(finalDate); + + if (kDebugMode) { + print( + '📅 Fecha final de procesamiento (más reciente global): $formattedFinalDate'); + } + + // Call the new function to finalize the sync process + await _finalizeSyncProcess( + processedItems, totalItems, mostRecentDateGlobal); + } + + if (_syncActivityId != null && + Platform.isIOS && + _liveActivitiesService.isSupported) { + try { + await _liveActivitiesService.endSearchActivity(_syncActivityId!); + _syncActivityId = null; + } catch (e) { + if (kDebugMode) { + print('Error ending Live Activity: $e'); + } + } + } + + database.close(); + _goHome(); + } catch (e) { + if (kDebugMode) print('❌ Error general: $e'); + setState(() { + _updateInfo = 'sync_error'.tr(); + }); + + if (_syncActivityId != null && + Platform.isIOS && + _liveActivitiesService.isSupported) { + try { + await _liveActivitiesService.updateSearchActivity( + activityId: _syncActivityId!, + title: 'sync_error'.tr(), + query: e.toString(), + count: '0', + ); + await _liveActivitiesService.endSearchActivity(_syncActivityId!); + _syncActivityId = null; + } catch (e) { + if (kDebugMode) { + print('Error updating Live Activity: $e'); + } + } + } + + await Future.delayed(const Duration(seconds: 2)); + _goHome(); + } + } + + // Método para obtener el total de elementos optimizado + Future _getTotalItemsCount(String locale, String date) async { + try { + final countUrl = Uri.parse( + '$_baseUrl/items/activities_translations?filter[languages_code][_eq]=$locale&filter[_or][0][activities_id][date_updated][_gt]=$date&filter[_or][1][activities_id][date_created][_gt]=$date&aggregate[count]=*&access_token=$_token'); + + final countResponse = await http.get(countUrl).timeout( + const Duration(seconds: 5), + ); + + if (countResponse.statusCode == 200) { + final countJson = jsonDecode(utf8.decode(countResponse.bodyBytes)); + if (countJson['data'] != null && + countJson['data'] is List && + countJson['data'].isNotEmpty) { + final totalItems = countJson['data'][0]['count'] ?? 0; + if (kDebugMode) print('📈 Total de elementos: $totalItems'); + return totalItems; + } + } + } catch (e) { + if (kDebugMode) print('⚠️ Error obteniendo total de elementos: $e'); + } + return 0; + } + + // Función estática para procesar lotes de elementos en un isolate + static Future> _processBatchItemsIsolate( + List data) async { + final List batchMessages = []; + final List> mimirDocuments = []; + + for (final item in data) { + try { + final draft = _processDraftItemIsolate(item); + if (draft != null) { + // Guardar solo la información general en la base de datos + batchMessages.add(draft); + + // Guardar el contenido completo en Mimir + if (draft.body != null && draft.body!.isNotEmpty) { + mimirDocuments.add({ + 'id': draft.id, + 'languages_code': draft.languagesCode, + 'content': draft.body!, + }); + } + } + } catch (e) { + if (kDebugMode) print('⚠️ Error procesando item en isolate: $e'); + continue; + } + } + + return { + 'messages': batchMessages, + 'mimirDocuments': mimirDocuments, + }; + } + + // Versión estática del método _processDraftItem para usar en isolates + static Draft? _processDraftItemIsolate(Map item) { + final String id = _safeStringStatic(item['activities_id']?['id']); + if (id.isEmpty) return null; + + // Manejar el caso cuando interventions está vacío + String body = ''; + if (item['interventions'] != null && + item['interventions'] is List && + (item['interventions'] as List).isNotEmpty) { + body = _safeStringStatic(item['interventions'][0]['text']); + } + + return Draft( + id: id, + title: _safeStringStatic(item['title']), + pdf: _safeStringStatic(item['pdf']), + date: _parseDate(item['activities_id']?['date']), + body: body, + activity: item['activities_id']?['activity'] is int + ? item['activities_id']['activity'] + : 1, + country: _safeStringStatic(item['activities_id']?['country']), + city: _safeStringStatic(item['activities_id']?['city']), + thumbnail: _safeStringStatic(item['activities_id']?['thumbnail']), + draft: item['activities_id']?['draft'] == true ? 1 : 0, + locale: _safeStringStatic(item['languages_code']), + languagesCode: _safeStringStatic(item['languages_code'])); + } + + // Versión estática de _safeString para usar en isolates + static String _safeStringStatic(dynamic value) { + if (value == null) return ''; + return value.toString(); + } + + // Versión estática del método _parseDate para usar en isolates + static DateTime _parseDate(String? dateStr) { + if (dateStr == null) return DateTime.now(); + try { + return DateTime.parse(dateStr); + } catch (e) { + return DateTime.now(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFFffffff), Color(0xFFe3ead6)], + begin: Alignment.topRight, + end: Alignment.bottomLeft)), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(), + _buildVersion(), + const SizedBox(height: 20), + + // Mostrar información de actualización + AnimatedOpacity( + opacity: _updateInfo.isNotEmpty ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: Text( + _updateInfo, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + + const SizedBox(height: 8), + + // Mostrar progreso según el estado + if (_updateProgress > 0 && _updateProgress < 1) + // Barra de progreso determinado (cuando hay algo que procesar) + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + '${(_updateProgress * 100).floor()}%', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0XFF6b8e23), + ), + ), + const SizedBox(height: 4), + TweenAnimationBuilder( + tween: Tween( + begin: 0, end: _updateProgress), + duration: + const Duration(milliseconds: 500), + curve: Curves.easeInOut, + builder: (context, value, child) { + return ClipRRect( + borderRadius: + BorderRadius.circular(10), + child: LinearProgressIndicator( + value: value, + color: const Color(0XFF6b8e23), + backgroundColor: + const Color(0xFFe5ebd8), + minHeight: 4, + ), + ); + }, + ), + ], + ) + else if (_updateProgress == 0 && + _updateInfo.isNotEmpty) + // Indicador de progreso indeterminado (durante verificaciones) + ClipRRect( + borderRadius: BorderRadius.circular(56), + child: const LinearProgressIndicator( + value: null, // Modo indeterminado + color: Color(0XFF6b8e23), + backgroundColor: Color(0xFFe5ebd8), + minHeight: 4, + ), + ), + ])), + + // Logo con animación sutil + AnimatedContainer( + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + padding: const EdgeInsets.all(24), + child: Center( + child: _buildLogo(), + ), + ) + ]))))); + } + + Widget _buildTitle() { + return Text('title'.tr(), + style: const TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: Color(0xFF333333), + height: 1)); + } + + Widget _buildVersion() { + return Text('${'version'.tr()} $_version', + style: const TextStyle( + fontSize: 20, + color: Color(0xFF666666), + fontWeight: FontWeight.w300, + letterSpacing: 0.5)); + } + + Widget _buildLogo() { + return SvgPicture.asset('assets/svg/logo.svg', + height: MediaQuery.of(context).size.height * 0.12, + width: MediaQuery.of(context).size.width * 0.3, + semanticsLabel: 'Logo LGCC'); + } + + Future _showSyncNotification(String message) async { + await NotificationController.showNotification( + title: 'Data Sync', + body: message, + payload: 'sync_status', + ); + } + + // Método para sincronizar Mimir con la base de datos en un Isolate + Future _syncMimirWithDatabase(String locale) async { + final token = RootIsolateToken.instance; + if (token == null) { + throw Exception('RootIsolateToken is not initialized'); + } + + setState(() { + _updateInfo = 'syncing_search_index'.tr(); + _updateProgress = 0.0; + }); + + // Ejecutar la sincronización en un Isolate + final syncResult = await compute( + _syncMimirWithDatabaseIsolate, + [locale, token], + ); + + // Actualizar la UI con el progreso + if (syncResult['success']) { + setState(() { + _updateProgress = 1.0; + _updateInfo = 'search_index_updated'.tr(); + }); + await Future.delayed(const Duration(milliseconds: 500)); + } else { + if (kDebugMode) { + print('❌ Error en sincronización: ${syncResult['error']}'); + } + } + } + + // Función estática para ejecutar en un Isolate - Verificar estadísticas de DB y Mimir + static Future> _checkDatabaseStatsIsolate( + List params) async { + final locale = params[0] as String; + final token = params[1] as RootIsolateToken; + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + + final database = AppDatabase(); + final mimirService = MimirService(); + + try { + final mimirCount = await mimirService.getDocumentCount(); + final dbMessages = await database.getAllMessages(locale); + + return { + 'mimirCount': mimirCount, + 'dbCount': dbMessages.length, + }; + } catch (e) { + if (kDebugMode) { + print('Error en _checkDatabaseStatsIsolate: $e'); + } + return { + 'mimirCount': 0, + 'dbCount': 0, + 'error': e.toString(), + }; + } finally { + database.close(); + } + } + + // Función estática para ejecutar en un Isolate - Sincronizar Mimir con la base de datos + static Future> _syncMimirWithDatabaseIsolate( + List params) async { + final locale = params[0] as String; + final token = params[1] as RootIsolateToken; + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + + final database = AppDatabase(); + final mimirService = MimirService(); + + try { + // Obtener todos los documentos de Mimir + final mimirDocuments = await mimirService.getAllDocuments(); + final mimirCount = mimirDocuments.length; + + // Crear un conjunto de IDs de documentos que ya están en Mimir + final mimirIds = + Set.from(mimirDocuments.map((doc) => doc['id'] as String)); + + // Obtener todos los mensajes de la base de datos + final messages = await database.getAllMessages(locale); + final dbCount = messages.length; + + if (kDebugMode) { + print('📊 Documentos en Mimir: $mimirCount'); + print('📊 Documentos en Base de Datos: $dbCount'); + } + + // Encontrar documentos que necesitan ser añadidos (en DB pero no en Mimir) + final documentsToAdd = messages.where((draft) { + return !mimirIds.contains(draft.id) && + draft.body != null && + draft.body!.isNotEmpty; + }).toList(); + + if (documentsToAdd.isNotEmpty) { + if (kDebugMode) { + print('🔄 Documentos a agregar a Mimir: ${documentsToAdd.length}'); + } + + // Preparar documentos para Mimir + final documents = documentsToAdd.map((draft) { + return { + 'id': draft.id, + 'languages_code': draft.languagesCode, + 'content': draft.body!, + }; + }).toList(); + + if (kDebugMode) { + print('📝 Documentos válidos para indexar: ${documents.length}'); + } + + // Añadir documentos a Mimir en lotes + const batchSize = 50; + for (var i = 0; i < documents.length; i += batchSize) { + final end = (i + batchSize < documents.length) + ? i + batchSize + : documents.length; + final batch = documents.sublist(i, end); + await mimirService.addDocuments(batch); + + if (kDebugMode) { + print( + '✓ Progreso de indexación: ${i + batch.length}/${documents.length}'); + } + } + + if (kDebugMode) { + final finalCount = await mimirService.getDocumentCount(); + print('✅ Indexación completada. Documentos en Mimir: $finalCount'); + } + } else { + if (kDebugMode) { + print('✅ Mimir y Base de Datos están sincronizados'); + } + } + + return {'success': true}; + } catch (e) { + if (kDebugMode) { + print('Error en _syncMimirWithDatabaseIsolate: $e'); + } + return { + 'success': false, + 'error': e.toString(), + }; + } finally { + database.close(); + } + } + + Future getDate() async { + final locale = await ConfigService.getLocale(); + try { + // Get the stored date from ConfigService + final storedDateStr = await ConfigService.getLastDate(locale); + + if (kDebugMode) { + print('📅 Raw stored date: $storedDateStr'); + } + + // Default to a very old date if stored date is empty or "0" + if (storedDateStr == '0' || storedDateStr.isEmpty) { + const defaultDate = '2000-01-01T00:00:00'; + if (kDebugMode) { + print('📅 Using default old date: $defaultDate'); + } + return defaultDate; + } + + // Check if the stored date is valid + try { + final storedDate = DateTime.parse(storedDateStr); + final now = DateTime.now(); + + // If the stored date is in the future, return yesterday's date instead + if (storedDate.isAfter(now)) { + final yesterday = now.subtract(const Duration(days: 1)); + final yesterdayStr = + DateFormat('yyyy-MM-ddTHH:mm:ss').format(yesterday); + + if (kDebugMode) { + print('⚠️ WARNING: Stored date ($storedDateStr) is in the future!'); + print('📅 Using yesterday\'s date instead: $yesterdayStr'); + } + + // Save the corrected date for future use + await ConfigService.setLastDate(locale, yesterdayStr); + return yesterdayStr; + } + + // Date is valid and not in the future + if (kDebugMode) { + print('📅 Using stored date: $storedDateStr'); + } + return storedDateStr; + } catch (e) { + // Invalid date format, use a very old date + const defaultDate = '2000-01-01T00:00:00'; + if (kDebugMode) { + print('⚠️ Error parsing date "$storedDateStr": $e'); + print('📅 Using default old date: $defaultDate'); + } + return defaultDate; + } + } catch (e) { + // Error retrieving date from ConfigService, use a very old date + const defaultDate = '2000-01-01T00:00:00'; + if (kDebugMode) { + print('❌ Error retrieving date: $e'); + print('📅 Using default old date: $defaultDate'); + } + return defaultDate; + } + } + + Future setDate([DateTime? specificDate]) async { + // Get the current date + final DateTime now = DateTime.now(); + + // Decide which date to use: + // 1. If specificDate is provided and not in the future, use it + // 2. Otherwise, use the current date + DateTime dateToUse = now; + + if (specificDate != null) { + if (specificDate.isAfter(now)) { + if (kDebugMode) { + print( + '⚠️ WARNING: Specified date (${specificDate.toIso8601String()}) is in the future!'); + print('📅 Using current date instead: ${now.toIso8601String()}'); + } + } else { + dateToUse = specificDate; + } + } + + // Format the date + final String formattedDate = + DateFormat('yyyy-MM-ddTHH:mm:ss').format(dateToUse); + + try { + // Save the date + final locale = await ConfigService.getLocale(); + await ConfigService.setLastDate(locale, formattedDate); + + if (kDebugMode) { + print('💾 Saved date: $formattedDate for locale: $locale'); + } + + // Verify the saved date + final savedDate = await ConfigService.getLastDate(locale); + if (kDebugMode) { + if (savedDate != formattedDate) { + print( + '⚠️ WARNING: Verification failed - expected: $formattedDate, got: $savedDate'); + } else { + print('✅ Date verification successful: $savedDate'); + } + } + + return formattedDate; + } catch (e) { + if (kDebugMode) { + print('❌ Error saving date: $e'); + } + return formattedDate; + } + } + + Future getLocale() async { + final locale = await ConfigService.getLocale(); + initializeDateFormatting(locale); + return locale; + } + + // Helper method to get the latest date from server + Future _getServerLatestDate(String locale) async { + try { + final url = Uri.parse( + '$_baseUrl/items/activities_translations?sort=-activities_id.date&fields=activities_id.date&filter[languages_code][_eq]=$locale&limit=1&access_token=$_token'); + + final response = await http.get(url).timeout(const Duration(seconds: 5)); + + if (response.statusCode == 200) { + final jsonResponse = jsonDecode(utf8.decode(response.bodyBytes)); + final List data = jsonResponse['data'] ?? []; + + if (data.isNotEmpty && data[0]['activities_id'] != null) { + return data[0]['activities_id']['date']; + } + } + } catch (e) { + if (kDebugMode) { + print('❌ Error getting server latest date: $e'); + } + } + return null; + } + + Future _initLiveActivities() async { + if (Platform.isIOS) { + if (kDebugMode) { + print("🔄 Initializing Live Activities service"); + } + + // If not on iOS, don't initialize LiveActivities + // Initialize LiveActivities with the app group + const String appGroupId = 'group.com.carpa.searchEngine.widget'; + const String urlScheme = 'searchengine://livesearch'; + + if (kDebugMode) { + print("📱 Using app group: $appGroupId"); + print("🔗 Using URL scheme: $urlScheme"); + } + + // Initialize Live Activities + final bool isInitialized = await _liveActivitiesService.init( + appGroupId: appGroupId, + urlScheme: urlScheme, + ); + + // Check if the initialization was successful + if (kDebugMode) { + print("LiveActivities initialized: $isInitialized"); + print( + "LiveActivities supported: ${_liveActivitiesService.isSupported}"); + print( + "LiveActivities appGroupId: ${_liveActivitiesService.appGroupId}"); + } + + // Request permissions for notifications to ensure we can show Live Activities + NotificationController.requestPermissions(); + } + } + + Future _finalizeSyncProcess(int processedItems, int totalItems, + DateTime? mostRecentDateGlobal) async { + if (processedItems > 0) { + // Always use the current date for the final update + final DateTime currentDate = DateTime.now(); + final String formattedCurrentDate = + DateFormat('yyyy-MM-ddTHH:mm:ss').format(currentDate); + + if (kDebugMode) { + print('📅 Using current date for final update: $formattedCurrentDate'); + if (mostRecentDateGlobal != null) { + print( + '📅 Most recent item date was: ${DateFormat('yyyy-MM-ddTHH:mm:ss').format(mostRecentDateGlobal)}'); + } + } + + // Save the current date + await setDate(currentDate); + + // Verify that the date was saved correctly + final savedDate = await getDate(); + if (kDebugMode) { + print('🔍 Verification - Current saved date: $savedDate'); + try { + final savedDateTime = DateTime.parse(savedDate); + final difference = + currentDate.difference(savedDateTime).inSeconds.abs(); + + if (difference > 5) { + print( + '⚠️ WARNING: Saved date differs from expected by $difference seconds'); + print('⚠️ Expected: $formattedCurrentDate, Got: $savedDate'); + } else { + print('✅ Date verification successful'); + } + } catch (e) { + print('❌ Date verification error: $e'); + } + } + + setState(() { + _updateProgress = 1.0; + _updateInfo = 'sync_complete'.tr(); + }); + + if (kDebugMode) { + print('✅ Sync completed'); + print('📊 Total documents processed: $processedItems'); + } + + // Update Live Activity to show the completion + if (_syncActivityId != null && + Platform.isIOS && + _liveActivitiesService.isSupported) { + try { + // First update with sync complete message + await _liveActivitiesService.updateSearchActivity( + activityId: _syncActivityId!, + title: 'sync_complete'.tr(), + query: + 'processed_items'.tr(args: ['$processedItems', '$totalItems']), + count: processedItems.toString(), + ); + + // Wait a moment for the update to be visible + await Future.delayed(const Duration(seconds: 2)); + + // Then end the activity + await _liveActivitiesService.endSearchActivity(_syncActivityId!); + _syncActivityId = null; + } catch (e) { + if (kDebugMode) { + print('Error finalizing Live Activity: $e'); + } + } + } + + await Future.delayed(const Duration(seconds: 1)); + await _showSyncNotification( + 'processed_items'.tr(args: ['$processedItems', '$totalItems'])); + } + } + + // Create a Live Activity with proper delay and verification + Future _createLiveActivity( + String title, String query, String count) async { + if (!Platform.isIOS) return null; + + try { + // Small delay to ensure everything is properly initialized + await Future.delayed(const Duration(milliseconds: 500)); + + // Verify the service is ready + if (!_liveActivitiesService.isInitialized || + !_liveActivitiesService.isSupported) { + if (kDebugMode) { + print( + 'Live Activities service not ready: initialized=${_liveActivitiesService.isInitialized}, supported=${_liveActivitiesService.isSupported}'); + } + return null; + } + + // Verify the appGroupId is set + if (_liveActivitiesService.appGroupId == null) { + if (kDebugMode) { + print('AppGroupId is null, reinitializing Live Activities...'); + } + + // Try to reinitialize + await _liveActivitiesService.init( + appGroupId: 'group.com.carpa.searchEngine.widget', + urlScheme: 'lgcc'); + + // Small delay after reinitialization + await Future.delayed(const Duration(milliseconds: 300)); + } + + // Create the activity + final activityId = await _liveActivitiesService.createSearchActivity( + title: title, + query: query, + count: count, + ); + + if (kDebugMode) { + print('Created Live Activity with ID: $activityId'); + } + + return activityId; + } catch (e) { + if (kDebugMode) { + print('Error creating Live Activity: $e'); + } + return null; + } + } + + // Helper method to update Live Activity safely + Future _updateLiveActivity( + String? activityId, String title, String query, String count) async { + if (activityId == null || !Platform.isIOS) return false; + + try { + // Verify the service is ready + if (!_liveActivitiesService.isInitialized || + !_liveActivitiesService.isSupported) { + if (kDebugMode) { + print('Live Activities service not ready for update'); + } + return false; + } + + // Perform the update + final success = await _liveActivitiesService.updateSearchActivity( + activityId: activityId, + title: title, + query: query, + count: count, + ); + + if (kDebugMode) { + print('Updated Live Activity $activityId: $success'); + } + + return success; + } catch (e) { + if (kDebugMode) { + print('Error updating Live Activity: $e'); + } + return false; + } + } + + // Helper method to end Live Activity safely + Future _endLiveActivity(String? activityId) async { + if (activityId == null || !Platform.isIOS) return false; + + try { + // Verify the service is ready + if (!_liveActivitiesService.isInitialized || + !_liveActivitiesService.isSupported) { + if (kDebugMode) { + print('Live Activities service not ready for ending activity'); + } + return false; + } + + // End the activity + final success = + await _liveActivitiesService.endSearchActivity(activityId); + + if (kDebugMode) { + print('Ended Live Activity $activityId: $success'); + } + + // Clear the activity ID + if (activityId == _syncActivityId) { + _syncActivityId = null; + } + + return success; + } catch (e) { + if (kDebugMode) { + print('Error ending Live Activity: $e'); + } + return false; + } + } +} diff --git a/lib/screens/pdf.dart b/lib/screens/pdf.dart new file mode 100644 index 0000000..9f336ac --- /dev/null +++ b/lib/screens/pdf.dart @@ -0,0 +1,226 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:percent_indicator/circular_percent_indicator.dart'; +import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; +import 'package:path_provider/path_provider.dart'; +import 'dart:io'; + +class FilePdf extends StatefulWidget { + final String pdf; + final String? searchTerm; + final String title; + + const FilePdf({ + super.key, + required this.pdf, + required this.title, + this.searchTerm, + }); + + @override + // ignore: library_private_types_in_public_api + _FilePdfState createState() => _FilePdfState(); +} + +class _FilePdfState extends State { + late Future _pdfPathFuture; + late PdfViewerController _pdfViewerController; + late PdfTextSearchResult _pdfTextSearchResult; + final _baseUrl = dotenv.env['BASE_URL']!; + final _token = dotenv.env['TOKEN']!; + bool isDownloading = true; + ValueNotifier downloadProgressNotifier = ValueNotifier(0); + bool isSearching = false; + int downloadProgress = 0; + Timer? _debounceTimer; + + @override + void initState() { + super.initState(); + _pdfPathFuture = _fetchPdf(); + _pdfViewerController = PdfViewerController(); + _pdfTextSearchResult = PdfTextSearchResult(); + } + + @override + void dispose() { + _pdfTextSearchResult.removeListener(() {}); + _pdfTextSearchResult.dispose(); + _pdfViewerController.dispose(); + _debounceTimer?.cancel(); + super.dispose(); + } + + Future _fetchPdf() async { + final String pdfFileName = widget.pdf; + final String baseUrl = '$_baseUrl/assets/$pdfFileName?access_token=$_token'; + + final appDir = await getApplicationDocumentsDirectory(); + final pdfDir = Directory( + '${appDir.path}/LGCC_Search/${context.locale.toString()}/library/'); + if (!await pdfDir.exists()) { + await pdfDir.create(recursive: true); + } + final String filePath = '${pdfDir.path}/$pdfFileName.pdf'; + if (await File(filePath).exists()) { + return filePath; + } else { + try { + await Dio().download(baseUrl, filePath, + onReceiveProgress: (actualBytes, totalBytes) { + final progress = (actualBytes / totalBytes * 100).floor(); + setState(() { + downloadProgress = progress; + downloadProgressNotifier.value = progress; + }); + }); + setState(() { + isDownloading = false; + }); + return filePath; + } catch (e) { + if (kDebugMode) { + print('Error downloading file: $e'); + } + setState(() { + isDownloading = false; + downloadProgress = 0; + downloadProgressNotifier.value = 0; + }); + return ''; + } + } + } + + Future saveDoc() async { + final List savedBytes = await _pdfViewerController.saveDocument(); + _saveDocument(savedBytes); + } + + void _performSearch(String searchTerm, {bool immediate = false}) { + if (mounted && searchTerm.isEmpty) { + setState(() { + isSearching = false; + }); + } + if (immediate) { + _performSearchAction(searchTerm); + } else { + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 300), () { + _performSearchAction(searchTerm); + }); + } + } + + void _performSearchAction(String searchTerm) { + _pdfTextSearchResult = _pdfViewerController.searchText(searchTerm); + _pdfTextSearchResult.addListener(() { + if (mounted && _pdfTextSearchResult.isSearchCompleted) { + setState(() {}); + } + }); + } + + Future _saveDocument(List dataBytes) async { + final appDir = await getApplicationDocumentsDirectory(); + final directory = Directory( + '${appDir.path}/LGCC_Search/${context.locale.toString()}/library/'); + final String path = directory.path; + final File file = File('$path/${widget.pdf}.pdf'); + await file.writeAsBytes(dataBytes); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: isSearching + ? TextField( + onChanged: (val) => _performSearch(val), + ) + : Text(widget.title), + backgroundColor: const Color(0xFFe0e6d1), + actions: [ + Container( + margin: const EdgeInsets.only(right: 12), + child: Row( + children: [ + if (_pdfTextSearchResult.hasResult && + _pdfTextSearchResult.totalInstanceCount > 1) + Text( + '${_pdfTextSearchResult.currentInstanceIndex} / ${_pdfTextSearchResult.totalInstanceCount}'), + if (_pdfTextSearchResult.hasResult && + _pdfTextSearchResult.totalInstanceCount > 1) + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: (_pdfTextSearchResult.currentInstanceIndex > 1) + ? () { + _pdfTextSearchResult.previousInstance(); + } + : null, + ), + if (_pdfTextSearchResult.hasResult && + _pdfTextSearchResult.totalInstanceCount > 1) + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: (_pdfTextSearchResult.currentInstanceIndex < + _pdfTextSearchResult.totalInstanceCount) + ? () { + _pdfTextSearchResult.nextInstance(); + } + : null, + ), + IconButton( + icon: Icon(isSearching ? Icons.close : Icons.search), + onPressed: () { + setState(() { + isSearching = !isSearching; + }); + }, + ), + ], + ), + ) + ]), + body: FutureBuilder( + future: _pdfPathFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Center( + child: CircularPercentIndicator( + backgroundColor: Colors.grey.shade400, + progressColor: const Color(0xFF6b8e23), + lineWidth: 3, + radius: 32, + center: Text(downloadProgress.toString(), + style: const TextStyle(fontSize: 14, color: Colors.black)), + percent: downloadProgress / 100, + ), + ); + } else if (snapshot.hasError) { + return Center(child: Text('no_results'.tr())); + } else if (snapshot.hasData) { + return SfPdfViewer.file( + File(snapshot.data!), + controller: _pdfViewerController, + onDocumentLoaded: (PdfDocumentLoadedDetails details) { + if (widget.searchTerm != null) { + _performSearch(widget.searchTerm!, immediate: true); + } + }, + onAnnotationAdded: (annotation) => saveDoc(), + ); + } else { + return const Center(child: Text('Error')); + } + }, + ), + ); + } +} diff --git a/lib/screens/search.dart b/lib/screens/search.dart new file mode 100644 index 0000000..fb82368 --- /dev/null +++ b/lib/screens/search.dart @@ -0,0 +1,1578 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:collection'; +import 'dart:math' as math; + +import 'package:country_codes/country_codes.dart'; +import 'package:dio/dio.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:search_engine/screens/content.dart'; +import 'package:search_engine/widgets/base.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import '../database.dart'; +import 'package:drift/drift.dart' as drift; +import 'package:search_engine/utils.dart' as utils; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart' + as nav; +import 'package:flutter/services.dart'; +import 'package:search_engine/services/mimir_service.dart'; + +// Clase auxiliar para representar una coincidencia +class _Match { + final int start; + final int end; + final String text; + + _Match({required this.start, required this.end, required this.text}); +} + +// Clase auxiliar para representar un fragmento de texto +class _TextFragment { + final String beforeMatch; + final String matchText; + final String afterMatch; + + _TextFragment({ + required this.beforeMatch, + required this.matchText, + required this.afterMatch, + }); +} + +// Clase para el caché de búsqueda +class SearchCache { + // LRU Cache con capacidad máxima + final int _maxSize; + final LinkedHashMap> _cache = LinkedHashMap(); + final Map _totalResultsCache = {}; + // Almacena todos los resultados de una búsqueda + final Map> _allResultsCache = {}; + // Almacena los resultados de Mimir (IDs y snippets) + final Map>> _mimirResultsCache = {}; + // Almacena todos los resultados de Mimir para una consulta + final Map>> _allMimirResultsCache = {}; + + SearchCache({int maxSize = 10}) : _maxSize = maxSize; + + // Obtener resultados del caché + List? get(String query) { + final normalizedQuery = query.trim().toLowerCase(); + if (!_cache.containsKey(normalizedQuery)) return null; + + // Mover el elemento al final (más reciente) + final results = _cache.remove(normalizedQuery); + _cache[normalizedQuery] = results!; + return results; + } + + // Guardar resultados en el caché + void put(String query, List results) { + final normalizedQuery = query.trim().toLowerCase(); + + // Si el caché está lleno, eliminar el elemento más antiguo + if (_cache.length >= _maxSize) { + final oldestKey = _cache.keys.first; + _cache.remove(oldestKey); + // También eliminar del caché de todos los resultados + _allResultsCache.remove(oldestKey); + _totalResultsCache.remove(oldestKey); + _mimirResultsCache.remove(oldestKey); + _allMimirResultsCache.remove(oldestKey); + } + + _cache[normalizedQuery] = results; + } + + // Limpiar el caché + void clear() { + _cache.clear(); + _totalResultsCache.clear(); + _allResultsCache.clear(); + _mimirResultsCache.clear(); + _allMimirResultsCache.clear(); + } + + // Obtener el total de resultados para una consulta + int? getTotalResults(String query) { + final normalizedQuery = query.trim().toLowerCase(); + return _totalResultsCache[normalizedQuery]; + } + + // Guardar el total de resultados para una consulta + void setTotalResults(String query, int total) { + final normalizedQuery = query.trim().toLowerCase(); + _totalResultsCache[normalizedQuery] = total; + } + + // Guardar todos los resultados de una búsqueda + void putAllResults(String query, List results) { + final normalizedQuery = query.trim().toLowerCase(); + _allResultsCache[normalizedQuery] = results; + } + + // Obtener todos los resultados de una búsqueda + List? getAllResults(String query) { + final normalizedQuery = query.trim().toLowerCase(); + return _allResultsCache[normalizedQuery]; + } + + // Guardar los resultados de Mimir (IDs y snippets) + void setMimirIds(String query, List> mimirResults) { + final normalizedQuery = query.trim().toLowerCase(); + _mimirResultsCache[normalizedQuery] = mimirResults; + // También actualizar el total de resultados + _totalResultsCache[normalizedQuery] = mimirResults.length; + } + + // Obtener los resultados de Mimir (IDs y snippets) + List>? getMimirIds(String query) { + final normalizedQuery = query.trim().toLowerCase(); + return _mimirResultsCache[normalizedQuery]; + } + + // Guardar todos los resultados de Mimir para una consulta + void setAllMimirResults(String query, List> results) { + final normalizedQuery = query.trim().toLowerCase(); + _allMimirResultsCache[normalizedQuery] = results; + } + + // Obtener todos los resultados de Mimir para una consulta + List>? getAllMimirResults(String query) { + final normalizedQuery = query.trim().toLowerCase(); + return _allMimirResultsCache[normalizedQuery]; + } +} + +class SearchPage extends StatefulWidget { + final String? searchTerm; + + const SearchPage({super.key, this.searchTerm}); + + @override + // ignore: library_private_types_in_public_api + _SearchPageState createState() => _SearchPageState(); +} + +class _SearchPageState extends State with TickerProviderStateMixin { + late TextEditingController _searchController; + late ScrollController _scrollController; + late AppDatabase database; + late Directory appDirectory; + int resultCount = 0; + int totalResults = 0; + bool isLoading = false; + // Nuevas variables para separar las fases de carga + bool mimirLoading = false; // Para la fase de obtención de IDs de Mimir + bool resultsLoading = false; // Para la fase de carga de detalles + Timer? _debounce; + final _baseUrl = dotenv.env['BASE_URL']; + final _token = dotenv.env['TOKEN']; + bool _isSearching = false; + late AnimationController _animationController; + late Animation _fadeAnimation; + + // Controlador para la animación de pulso del indicador de búsqueda + late AnimationController _pulseController; + late Animation _pulseAnimation; + + // Controlador de paginación + final PagingController _pagingController = PagingController( + firstPageKey: 1, + ); + + // Tamaño de página para la carga incremental + static const _pageSize = 10; + + // Consulta actual + String _currentQuery = ''; + + // Almacena todos los resultados de la búsqueda actual + List? _allSearchResults; + + // Instancia del caché de búsqueda + final SearchCache _searchCache = SearchCache(maxSize: 20); + + final _mimirService = MimirService(); + + // Caché para nombres de países + final Map _countryNameCache = {}; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(text: widget.searchTerm); + _scrollController = ScrollController(); + _initAppDirectory(); + database = AppDatabase(); + _mimirService.initialize(); + + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + + _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeIn, + ), + ); + + // Inicializar el controlador de la animación de pulso + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(reverse: true); + + _pulseAnimation = Tween(begin: 0.8, end: 1.2).animate( + CurvedAnimation( + parent: _pulseController, + curve: Curves.easeInOut, + ), + ); + + // Configurar el listener para la paginación + _pagingController.addPageRequestListener((pageKey) { + if (_currentQuery.isNotEmpty) { + _fetchPage(pageKey); + } + }); + + if (_searchController.text.isNotEmpty) { + Timer(const Duration(milliseconds: 100), () { + _onSearch(_searchController.text); + }); + } + } + + @override + void dispose() { + _pagingController.dispose(); + _animationController.dispose(); + _pulseController.dispose(); + _scrollController.dispose(); + database.close(); + _debounce?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + void _onSearch(String value) { + if (_searchController.text.trim().isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _isSearching = true; + _currentQuery = _searchController.text; + totalResults = 0; + isLoading = true; + mimirLoading = true; + resultsLoading = true; + _allSearchResults = null; + }); + + // Limpiar caché de resaltado para la nueva búsqueda + _highlightedSpansCache.clear(); + + // Refrescar el controlador de paginación + _pagingController.refresh(); + _animationController.forward(); + } + }); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _isSearching = false; + _currentQuery = ''; + totalResults = 0; + isLoading = false; + mimirLoading = false; + resultsLoading = false; + _allSearchResults = null; + }); + + // Limpiar caché de resaltado + _highlightedSpansCache.clear(); + + // Limpiar y refrescar el controlador de paginación + _pagingController.itemList?.clear(); + _pagingController.refresh(); + + if (_animationController.isAnimating) { + _animationController.stop(); + } + if (_animationController.value > 0) { + _animationController.reverse(); + } + } + }); + } + } + + void _clearSearch() { + _searchController.clear(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _isSearching = false; + _currentQuery = ''; + isLoading = false; + mimirLoading = false; + resultsLoading = false; + _allSearchResults = null; + }); + + // Limpiar caché de resaltado + _highlightedSpansCache.clear(); + + // Refrescar el controlador de paginación + _pagingController.refresh(); + + if (_animationController.isAnimating) { + _animationController.stop(); + } + if (_animationController.value > 0) { + _animationController.reverse(); + } + } + }); + } + + // Nueva función para búsqueda completa en Mimir + static Future> _searchMimirFullIsolate( + List params) async { + final query = params[0] as String; + final locale = params[1] as String; + final token = params[2] as RootIsolateToken; + + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + final mimirService = MimirService(); + final response = await mimirService.search( + query, + locale, + limit: 10000, + offset: 0, + ); + return response; + } + + Future _fetchPage(int pageKey) async { + if (!mounted) return; + + try { + // Check if results are in cache + final cachedResults = _searchCache.get('${_currentQuery}_page_$pageKey'); + + if (cachedResults != null) { + final isLastPage = cachedResults.length < _pageSize; + if (isLastPage) { + _pagingController.appendLastPage(cachedResults); + } else { + _pagingController.appendPage(cachedResults, pageKey + 1); + } + return; + } + + // Calculate offset and limit for pagination + final offset = (pageKey - 1) * _pageSize; + final limit = _pageSize; + + // Show loading state immediately + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + isLoading = true; + mimirLoading = + pageKey == 1; // Only show Mimir loading for first page + resultsLoading = true; + }); + } + }); + + // Get the RootIsolateToken before starting the search + final token = RootIsolateToken.instance; + if (token == null) { + throw Exception('RootIsolateToken is not initialized'); + } + + // For the first page, perform the complete Mimir search + List> mimirResults; + int total = 0; + + if (pageKey == 1) { + // For the first page, do a complete search in Mimir + // Using Future to avoid blocking the UI + final searchResponse = await _mimirService.search( + _currentQuery, + context.locale.toString(), + limit: 500, // Increased limit to show more results + offset: 0, + ); + + // Obtener resultados únicos por ID de documento + final allMimirResults = + (searchResponse['results'] as List>) + .fold>>({}, (map, result) { + if (!map.containsKey(result['id'])) { + map[result['id']!] = result; + } + return map; + }) + .values + .toList(); + + // Use the total from the search response, not just the length of the filtered results + total = searchResponse['total'] + as int; // This will get the actual total from the search engine + + // Update total results immediately, after the frame + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + totalResults = total; + mimirLoading = false; + }); + } + }); + + // Store all Mimir results for future use + _searchCache.setAllMimirResults(_currentQuery, allMimirResults); + _searchCache.setTotalResults(_currentQuery, total); + + // Get only the results for the first page + mimirResults = + allMimirResults.sublist(0, limit.clamp(0, allMimirResults.length)); + + // If there are no results, finish here + if (mimirResults.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + isLoading = false; + resultsLoading = false; + }); + _pagingController.appendLastPage([]); + } + }); + return; + } + + // Use compute to process results in the background + final List ids = + mimirResults.map((result) => result['id']!).toList(); + final String locale = context.locale.toString(); + + // Prepare parameters for computation + final computeParams = { + 'ids': ids, + 'locale': locale, + 'mimirResults': mimirResults, + 'token': token, + }; + + // Process database query in compute function + compute, List>( + _processDbResultsInBackground, computeParams) + .then((pageResults) { + // Save this page in cache + _searchCache.put('${_currentQuery}_page_$pageKey', pageResults); + + // Update state after the frame + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + isLoading = false; + resultsLoading = false; + }); + + // Show complete results + final isLastPage = pageResults.length < _pageSize; + if (isLastPage) { + _pagingController.appendLastPage(pageResults); + } else { + _pagingController.appendPage(pageResults, pageKey + 1); + } + } + }); + }).catchError((error) { + if (kDebugMode) { + print('Error processing DB results: $error'); + } + + // In case of error, show basic results after the frame + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + isLoading = false; + resultsLoading = false; + }); + _pagingController.error = error; + } + }); + }); + } else { + // For subsequent pages, use stored results + final allMimirResults = _searchCache.getAllMimirResults(_currentQuery); + if (allMimirResults == null) { + _pagingController.appendLastPage([]); + return; + } + + total = _searchCache.getTotalResults(_currentQuery) ?? 0; + final start = offset.clamp(0, allMimirResults.length); + final end = (offset + limit).clamp(0, allMimirResults.length); + mimirResults = allMimirResults.sublist(start, end); + + // Similar processing for subsequent pages + final List ids = + mimirResults.map((result) => result['id']!).toList(); + final String locale = context.locale.toString(); + + // Prepare parameters for computation + final computeParams = { + 'ids': ids, + 'locale': locale, + 'mimirResults': mimirResults, + 'token': token, + }; + + // Process database query in compute function + compute, List>( + _processDbResultsInBackground, computeParams) + .then((pageResults) { + // Save this page in cache + _searchCache.put('${_currentQuery}_page_$pageKey', pageResults); + + // Show results + final isLastPage = pageResults.length < _pageSize; + if (isLastPage) { + _pagingController.appendLastPage(pageResults); + } else { + _pagingController.appendPage(pageResults, pageKey + 1); + } + + // Update state + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + isLoading = false; + resultsLoading = false; + }); + } + }); + }).catchError((error) { + if (kDebugMode) { + print('Error loading DB details on page $pageKey: $error'); + } + _pagingController.error = error; + }); + } + } catch (e) { + if (kDebugMode) { + print('Error in pagination: $e'); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + isLoading = false; + mimirLoading = false; + resultsLoading = false; + }); + _pagingController.error = e; + } + }); + } + } + + // Background processing function for database operations + static Future> _processDbResultsInBackground( + Map params) async { + final List ids = params['ids']; + final String locale = params['locale']; + final List> mimirResults = params['mimirResults']; + final RootIsolateToken token = params['token']; + + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + + final database = AppDatabase(); + + try { + // Single query to get all details + final String searchQuery = ''' + SELECT DISTINCT + m.id AS id, + t.title AS title, + m.country AS country, + m.city AS city, + m.activity AS activity, + m.draft AS draft, + m.thumbnail AS thumbnail, + t.languages_code AS locale, + m.date AS date, + t.languages_code AS languages_code, + t.pdf AS pdf + FROM + messages AS m + LEFT JOIN + translations AS t + ON + m.id = t.message_id + WHERE + t.languages_code = ? + AND m.id IN (${List.filled(ids.length, '?').join(',')}) + ORDER BY m.date DESC + '''; + + // Prepare parameters: first the locale, then the IDs + final variables = [ + drift.Variable.withString(locale), + ...ids.map((id) => drift.Variable.withString(id)), + ]; + + final dbResults = await database + .customSelect( + searchQuery, + variables: variables, + ) + .get(); + + // Create a map to relate IDs with DB results + final Map> resultsMap = {}; + + for (final row in dbResults) { + final id = row.read('id'); + resultsMap[id] = { + 'id': id, + 'title': row.read('title') ?? '', + 'date': row.read('date'), + 'activity': row.read('activity') ?? 0, + 'thumbnail': row.read('thumbnail') ?? '', + 'draft': row.read('draft') ?? 0, + 'locale': row.read('locale') ?? '', + 'country': row.read('country') ?? '', + 'city': row.read('city') ?? '', + 'pdf': row.read('pdf') ?? '', + 'languagesCode': row.read('languages_code') ?? '', + }; + } + + // Combine Mimir information with DB results + final List pageResults = []; + for (final result in mimirResults) { + final id = result['id']!; + if (resultsMap.containsKey(id)) { + final dbData = resultsMap[id]!; + // Create a new Draft object with DB and Mimir data + final draft = Draft( + id: id, + title: dbData['title'] as String, + date: dbData['date'] as DateTime, + activity: dbData['activity'] as int, + thumbnail: dbData['thumbnail'] as String, + draft: dbData['draft'] as int, + locale: dbData['locale'] as String, + body: result['content']!, + position: int.tryParse(result['position'] ?? '0') ?? 0, + length: int.tryParse(result['length'] ?? '0') ?? 0, + country: dbData['country'] as String, + city: dbData['city'] as String, + pdf: dbData['pdf'] as String, + languagesCode: dbData['languagesCode'] as String, + ); + pageResults.add(draft); + } + } + + return pageResults; + } catch (e) { + // If there's an error, create basic results with available information + final List basicResults = mimirResults.map((result) { + final content = result['content'] ?? ''; + // Extract a title from content if possible + String title = 'No title'; + if (content.length > 30) { + title = content.substring(0, 30).replaceAll('\n', ' ') + '...'; + } else if (content.isNotEmpty) { + title = content.replaceAll('\n', ' '); + } + + return Draft( + id: result['id']!, + title: title, + date: DateTime.now(), + activity: 0, + thumbnail: '', + draft: 0, + locale: locale, + body: content, + position: int.tryParse(result['position'] ?? '0') ?? 0, + length: int.tryParse(result['length'] ?? '0') ?? 0, + country: '', + city: '', + pdf: '', + languagesCode: locale, + ); + }).toList(); + + return basicResults; + } finally { + // Close database connection when done + database.close(); + } + } + + Future _initAppDirectory() async { + appDirectory = await getApplicationDocumentsDirectory(); + } + + Future _getThumbnail(String directoryPath, String fileId) async { + final dir = Directory(directoryPath); + + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + final entities = await dir.list().toList(); + + for (var entity in entities) { + if (entity is File) { + final fileName = entity.uri.pathSegments.last; + if (fileName == '$fileId+SD.jpg') { + return entity; + } + } + } + + final String url = + '$_baseUrl/assets/$fileId?access_token=$_token&width=320&height=180&quality=50&fit=cover&format=jpg'; + final String thumbnailPath = '$directoryPath/$fileId+SD.jpg'; + if (fileId == '') { + return null; + } + try { + await Dio().download(url, thumbnailPath); + return File(thumbnailPath); + } catch (e) { + if (kDebugMode) { + print('Error downloading thumbnail: $e'); + } + return null; + } + } + + Widget _buildThumbnail(String thumbnailId) { + // Evitar cargar miniaturas para thumbnailId vacío + if (thumbnailId.isEmpty) { + return Image.asset( + 'assets/image/default_thumbnail.jpg', + height: 80, + width: 100, + fit: BoxFit.cover, + alignment: Alignment.center, + cacheHeight: 160, + cacheWidth: 200, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + ); + } + + return FutureBuilder( + future: _getThumbnail( + '${appDirectory.path}/LGCC_Search/${context.locale.toString()}/thumbnails/', + thumbnailId), + builder: (context, snapshot) { + return Skeletonizer( + key: ValueKey( + 'skeleton_thumbnail_${thumbnailId}_${snapshot.connectionState}'), + enableSwitchAnimation: true, + enabled: snapshot.connectionState != ConnectionState.done, + effect: const ShimmerEffect( + baseColor: Color(0xFFf1f5eb), + highlightColor: Colors.white30, + duration: Duration(milliseconds: 1000), + ), + child: snapshot.hasData && snapshot.data != null + ? Image.file( + snapshot.data!, + height: 80, + width: 100, + fit: BoxFit.cover, + cacheHeight: 180, + cacheWidth: 320, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + errorBuilder: (context, error, stackTrace) { + return Image.asset( + 'assets/image/default_thumbnail.jpg', + height: 80, + width: 100, + fit: BoxFit.cover, + ); + }, + ) + : Image.asset( + 'assets/image/default_thumbnail.jpg', + height: 80, + width: 100, + fit: BoxFit.cover, + alignment: Alignment.center, + cacheHeight: 160, + cacheWidth: 200, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + ), + ); + }, + ); + } + + // Modified to handle highlighting efficiently + Future> _getHighlightedSpansAsync( + String text, String searchText) { + if (searchText.isEmpty) { + return Future.value([TextSpan(text: text)]); + } + + // The text is already clean of HTML, we can use it directly + final String plainText = text; + + // If the text is very short, don't process it + if (plainText.length < 3) { + return Future.value([TextSpan(text: plainText)]); + } + + // Cache for results to avoid recalculations + final String cacheKey = '$plainText:$searchText'; + if (_highlightedSpansCache.containsKey(cacheKey)) { + return Future.value(_highlightedSpansCache[cacheKey]!); + } + + // Run the highlighting in a compute function to avoid blocking the UI + return compute(_computeHighlightedSpans, [plainText, searchText]) + .then((spans) { + _highlightedSpansCache[cacheKey] = spans; + return spans; + }).catchError((error) { + // Fallback in case of error + return [TextSpan(text: plainText)]; + }); + } + + // Static method for compute + static List _computeHighlightedSpans(List params) { + final String plainText = params[0]; + final String searchText = params[1]; + + final List spans = []; + + // Extract keywords from the search and filter very short ones + final List keywords = searchText + .trim() + .toLowerCase() + .split(RegExp(r'\s+')) + .where((word) => word.isNotEmpty && word.length >= 3) + .toList(); + + if (keywords.isEmpty) { + return [TextSpan(text: plainText)]; + } + + // Text in lowercase for comparisons + final String lowerText = plainText.toLowerCase(); + + // Create a list of all matches + final List<_Match> allMatches = []; + + // Use a more efficient approach to find matches + for (final keyword in keywords) { + // For words longer than 4 letters, consider the root as the first n-1 letters + String baseWord = keyword; + if (baseWord.length > 4) { + baseWord = baseWord.substring(0, baseWord.length - 1); + } + + // Search for the base word in the text + int startIndex = 0; + while (true) { + final int index = lowerText.indexOf(baseWord, startIndex); + if (index == -1) break; + + // Find the end of the word + int endIndex = index + baseWord.length; + while (endIndex < lowerText.length && + _isWordCharacter(lowerText[endIndex])) { + endIndex++; + } + + // Add the match + allMatches.add(_Match( + start: index, + end: endIndex, + text: plainText.substring(index, endIndex), + )); + + // Continue from the end of this match + startIndex = endIndex; + } + } + + // If there are no matches, return the original text + if (allMatches.isEmpty) { + return [TextSpan(text: plainText)]; + } + + // Sort matches by position + allMatches.sort((a, b) => a.start.compareTo(b.start)); + + // Handle overlapping matches + final List<_Match> mergedMatches = []; + if (allMatches.isNotEmpty) { + _Match current = allMatches.first; + for (int i = 1; i < allMatches.length; i++) { + final _Match next = allMatches[i]; + if (current.end >= next.start) { + // Matches overlap, merge them + current = _Match( + start: current.start, + end: math.max(current.end, next.end), + text: plainText.substring( + current.start, math.max(current.end, next.end)), + ); + } else { + // No overlap, add the current one to the result and move to the next + mergedMatches.add(current); + current = next; + } + } + mergedMatches.add(current); // Add the last match + } + + // Build spans from merged matches + int lastIndex = 0; + for (final match in mergedMatches) { + if (match.start > lastIndex) { + spans.add(TextSpan( + text: plainText.substring(lastIndex, match.start), + )); + } + + spans.add(TextSpan( + text: plainText.substring(match.start, match.end), + style: const TextStyle(backgroundColor: Color(0xFFfff930)), + )); + + lastIndex = match.end; + } + + // Add remaining text + if (lastIndex < plainText.length) { + spans.add(TextSpan( + text: plainText.substring(lastIndex), + )); + } + + return spans; + } + + // Check if a character is part of a word + static bool _isWordCharacter(String char) { + return RegExp(r'[a-zñáéíóúüA-ZÑÁÉÍÓÚÜ0-9]').hasMatch(char); + } + + // Caché para los spans resaltados + final Map> _highlightedSpansCache = {}; + + String _getCountryName(String countryCode) { + if (countryCode.isEmpty) { + return 'N/A'; + } + + try { + return CountryCodes.detailsFromAlpha2(countryCode).name.toString(); + } catch (e) { + // Si no se encuentra el código de país, devolver el código como está + return countryCode; + } + } + + // Widget optimizado para texto de ubicación + Widget _buildLocationText(Draft message) { + // Evitar cálculos costosos en cada reconstrucción + final String locationText = message.city.isNotEmpty + ? '${message.city}, ${_getCountryNameCached(message.country)}' + : _getCountryNameCached(message.country); + + return Text( + locationText, + style: const TextStyle(fontSize: 14, height: 1.2), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } + + // Versión en caché de _getCountryName + String _getCountryNameCached(String countryCode) { + if (countryCode.isEmpty) { + return 'N/A'; + } + + if (_countryNameCache.containsKey(countryCode)) { + return _countryNameCache[countryCode]!; + } + + try { + final name = CountryCodes.detailsFromAlpha2(countryCode).name.toString(); + _countryNameCache[countryCode] = name; + return name; + } catch (e) { + _countryNameCache[countryCode] = countryCode; + return countryCode; + } + } + + @override + Widget build(BuildContext context) { + return BaseScreen( + title: 'search'.tr(), + showSearchBar: true, + showSettingsButton: true, + searchController: _searchController, + onSearchChanged: (_) {}, + onSearchSubmitted: _onSearch, + searchHintText: 'search_placeholder'.tr(), + child: Stack( + children: [ + // Logo cuando no hay búsqueda activa + if (!_isSearching || _currentQuery.isEmpty) + Positioned( + bottom: 80.0, + left: 0, + right: 0, + child: Center( + child: Image.asset( + 'assets/image/logo.png', + width: 200, + ), + ), + ), + + // Resultados de búsqueda + if (_isSearching) + Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSearchHeader(), + const SizedBox(height: 16), + Expanded( + child: PagedListView( + pagingController: _pagingController, + scrollController: _scrollController, + addAutomaticKeepAlives: false, + addRepaintBoundaries: true, + physics: const AlwaysScrollableScrollPhysics(), + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, message, index) => + _buildDefaultItem(message), + firstPageProgressIndicatorBuilder: (_) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + return Transform.scale( + scale: _pulseAnimation.value, + child: child, + ); + }, + child: const CircularProgressIndicator( + color: Color(0XFF6b8e23), + strokeWidth: 3, + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 10), + decoration: BoxDecoration( + color: + const Color(0xFF6b8e23).withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFF6b8e23) + .withOpacity(0.3), + width: 1, + ), + ), + child: Text( + mimirLoading + ? 'searching_in_progress'.tr() + : 'loading_details'.tr(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF6b8e23), + ), + ), + ), + // Mostrar el contador de resultados tan pronto como tengamos los IDs de Mimir + // sin esperar a que se carguen los detalles + if (!mimirLoading && totalResults > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFF6b8e23) + .withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFF6b8e23) + .withOpacity(0.3), + width: 1, + ), + ), + child: Text( + "$totalResults ${'results'.plural(totalResults)} ${'found'.tr()}", + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF6b8e23), + ), + ), + ), + ), + ], + ), + ), + newPageProgressIndicatorBuilder: (_) => Container( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: + const Color(0xFF6b8e23).withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFF6b8e23) + .withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + return Transform.scale( + scale: _pulseAnimation.value, + child: child, + ); + }, + child: const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation( + Color(0xFF6b8e23)), + ), + ), + ), + const SizedBox(width: 10), + Text( + 'loading_more'.tr(), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF6b8e23), + ), + ), + ], + ), + ), + ], + ), + ), + noItemsFoundIndicatorBuilder: (_) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: + const Color(0xFF6b8e23).withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.search_off_rounded, + size: 48, + color: Color(0XFF6b8e23), + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: + const Color(0xFF6b8e23).withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFF6b8e23) + .withOpacity(0.3), + width: 1, + ), + ), + child: Text( + 'no_results'.tr(), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF6b8e23), + ), + ), + ), + const SizedBox(height: 8), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 40), + child: Text( + 'try_different_search'.tr(), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + // Widget para el encabezado de búsqueda + Widget _buildSearchHeader() { + Widget loadingIndicator(String text) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFF6b8e23).withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFF6b8e23).withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + return Transform.scale( + scale: _pulseAnimation.value, + child: child, + ); + }, + child: const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Color(0xFF6b8e23)), + ), + ), + ), + const SizedBox(width: 8), + Text( + text, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF6b8e23), + ), + ), + ], + ), + ); + } + + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + '${'searching'.tr()}: "$_currentQuery"', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + // Mostrar el contador de resultados tan pronto como tengamos los IDs de Mimir + // sin esperar a que se carguen los detalles + if (!mimirLoading && totalResults > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF6b8e23).withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + "$totalResults ${'results'.plural(totalResults)}", + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF6b8e23), + ), + ), + ), + ], + ), + ], + ), + ), + if (mimirLoading) + loadingIndicator('searching_in_progress'.tr()) + else if (resultsLoading && totalResults > 0) + loadingIndicator('loading_details'.tr()), + ], + ); + } + + Widget _buildDefaultItem(Draft message) { + // Usar un widget de construcción diferida para mejorar el rendimiento + return RepaintBoundary( + child: Card( + key: ValueKey( + 'search_result_${message.id}_${message.date.millisecondsSinceEpoch}'), + color: const Color(0xFFdfe6ce), + margin: const EdgeInsets.only(bottom: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: InkWell( + onTap: () => nav.pushScreenWithoutNavBar( + context, + TextViewer( + data: message, + searchTerm: message.body != null + ? _getMatchedText(message.body!, _currentQuery) + : _currentQuery)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + width: 100, + height: 80, + decoration: const BoxDecoration( + color: Color(0xFFf1f5eb), + ), + child: _buildThumbnail(message.thumbnail), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildItemTitle(message), + const SizedBox(height: 5), + Text( + utils.formatDate( + message.date, context.locale.toString()), + style: const TextStyle(fontSize: 14, height: 1), + ), + const SizedBox(height: 5), + _buildLocationText(message), + const SizedBox(height: 5), + Text( + "${plural('activity', 1)} ${message.activity}", + style: const TextStyle(fontSize: 14, height: 1), + ), + ], + ), + ), + ], + ), + if (message.body != null && message.body!.isNotEmpty) + _buildSnippet(message.body!, _currentQuery), + ], + ), + ), + ), + ), + ); + } + + // Widget optimizado para el título del item + Widget _buildItemTitle(Draft message) { + if (message.title.isEmpty) { + return Text( + utils.formatDate(message.date, context.locale.toString()), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + height: 1.2, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ); + } + + return _buildHighlightedText( + message.title, + _currentQuery, + const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + height: 1.2, + color: Colors.black, + fontFamily: 'Outfit', + ), + maxLines: 2, + ); + } + + // Widget optimizado para el snippet + Widget _buildSnippet(String snippet, String query) { + return Padding( + padding: const EdgeInsets.only(top: 16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + ), + child: _buildHighlightedText( + snippet, + query, + const TextStyle( + fontSize: 14, + color: Colors.black87, + height: 1.5, + fontFamily: 'Outfit', + ), + maxLines: 3, + ), + ), + ); + } + + // Widget optimizado para texto resaltado + Widget _buildHighlightedText(String text, String searchText, TextStyle style, + {int maxLines = 2}) { + // Use simple text display initially to prevent blocking + Widget simpleText = RichText( + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + text: TextSpan( + text: text, + style: style, + ), + textScaler: TextScaler.linear(1.0), + ); + + // If text is simple or search is empty, just show as is + if (text.length < 50 || searchText.isEmpty) { + return simpleText; + } + + // For complex highlighting, use an optimized approach with caching and fallback + final String cacheKey = '$text:$searchText'; + if (_highlightedSpansCache.containsKey(cacheKey)) { + // Use cached spans if available + return RichText( + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + text: TextSpan( + children: _highlightedSpansCache[cacheKey], + style: style, + ), + textScaler: TextScaler.linear(1.0), + ); + } + + // Trigger async highlighting and use simple text initially + _getHighlightedSpansAsync(text, searchText).then((spans) { + // Force a rebuild after spans are computed + if (mounted) setState(() {}); + }); + + return simpleText; + } + + String _getMatchedText(String content, String searchQuery) { + if (searchQuery.isEmpty) return ''; + + // Convertir a minúsculas para búsqueda insensible a mayúsculas/minúsculas + final lowerContent = content.toLowerCase(); + final lowerQuery = searchQuery.toLowerCase(); + + // Buscar la coincidencia exacta + final int index = lowerContent.indexOf(lowerQuery); + if (index != -1) { + // Devolver el texto original que coincidió, no la versión en minúsculas + return content.substring(index, index + searchQuery.length); + } + + // Si no hay coincidencia exacta, buscar palabras individuales + final queryWords = lowerQuery + .split(RegExp(r'\s+')) + .where((word) => word.length > 2) + .toList(); + + for (final word in queryWords) { + final wordIndex = lowerContent.indexOf(word); + if (wordIndex != -1) { + // Devolver la primera palabra que coincida + return content.substring(wordIndex, wordIndex + word.length); + } + } + + return searchQuery; // Fallback al término de búsqueda original + } +} diff --git a/lib/services/config_service.dart b/lib/services/config_service.dart new file mode 100644 index 0000000..17b6045 --- /dev/null +++ b/lib/services/config_service.dart @@ -0,0 +1,65 @@ +import 'dart:io'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ConfigService { + static const String _localeKey = 'locale'; + static const String _hdThumbnailsKey = 'hd_thumbnails'; + static const String _pdfDownloadKey = 'pdf_download'; + static const String _lastDateKey = 'last_date'; + + static Future getLocale() async { + final prefs = await SharedPreferences.getInstance(); + final deviceLocale = Platform.localeName.split('_')[0]; + return prefs.getString(_localeKey) ?? deviceLocale; + } + + static Future setLocale(String locale) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_localeKey, locale); + } + + static Future getHdThumbnails() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_hdThumbnailsKey) ?? true; + } + + static Future setHdThumbnails(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_hdThumbnailsKey, value); + } + + static Future getPdfDownload() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_pdfDownloadKey) ?? true; + } + + static Future setPdfDownload(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_pdfDownloadKey, value); + } + + static Future getLastDate(String locale) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('$_lastDateKey-$locale') ?? '0'; + } + + static Future setLastDate(String locale, String date) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('$_lastDateKey-$locale', date); + } + + static Future initializeConfig() async { + final prefs = await SharedPreferences.getInstance(); + + // Initialize default values if they don't exist + if (!prefs.containsKey(_hdThumbnailsKey)) { + await setHdThumbnails(true); + } + if (!prefs.containsKey(_pdfDownloadKey)) { + await setPdfDownload(true); + } + if (!prefs.containsKey(_localeKey)) { + await setLocale(Platform.localeName.split('_')[0]); + } + } +} diff --git a/lib/services/live_activities_service.dart b/lib/services/live_activities_service.dart new file mode 100644 index 0000000..1cd66c2 --- /dev/null +++ b/lib/services/live_activities_service.dart @@ -0,0 +1,381 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:live_activities/live_activities.dart'; + +class LiveActivitiesService { + // Use a singleton pattern + static final LiveActivitiesService _instance = + LiveActivitiesService._internal(); + + // LiveActivities plugin instance + final LiveActivities _liveActivities = LiveActivities(); + bool _isInitialized = false; + bool _isSupported = false; + String? _appGroupId; + String? _urlScheme; + + factory LiveActivitiesService() => _instance; + LiveActivitiesService._internal(); + + /// Initialize the Live Activities service with specific app group ID and URL scheme + Future init( + {required String appGroupId, required String urlScheme}) async { + debugPrint( + 'LiveActivitiesService: Initializing with appGroupId: $appGroupId, urlScheme: $urlScheme'); + + // Store directly in the instance + _appGroupId = appGroupId; + _urlScheme = urlScheme; + + // Initialize the plugin with the app group ID + try { + // Using the init method of the plugin with appGroupId + await _liveActivities.init(appGroupId: appGroupId); + debugPrint( + 'LiveActivitiesService: Initialized plugin with appGroupId: $appGroupId'); + } catch (e) { + debugPrint('LiveActivitiesService: Error initializing plugin: $e'); + } + + final result = await initialize(); + + // Debug verification + debugPrint( + 'LiveActivitiesService: After init - appGroupId: $_appGroupId, initialized: $_isInitialized, supported: $_isSupported'); + + return result; + } + + /// Initialize the Live Activities service + Future initialize() async { + if (_isInitialized) { + debugPrint( + 'LiveActivitiesService: Already initialized, supported: $_isSupported, appGroupId: $_appGroupId'); + return _isSupported; + } + + // Live Activities are only supported on iOS 16.1+ + if (!Platform.isIOS) { + debugPrint( + 'LiveActivitiesService: Platform is not iOS, Live Activities not supported'); + _isSupported = false; + _isInitialized = true; + return false; + } + + try { + // Request permissions explicitly + final permissionGranted = await _requestLiveActivitiesPermission(); + if (!permissionGranted) { + debugPrint('LiveActivitiesService: Permission not granted'); + _isSupported = false; + _isInitialized = true; + return false; + } + + // Check if Live Activities are supported on this device + _isSupported = await _liveActivities.areActivitiesEnabled(); + _isInitialized = true; + + debugPrint( + 'LiveActivitiesService: Initialized, supported: $_isSupported, appGroupId: $_appGroupId'); + return _isSupported; + } catch (e) { + debugPrint('LiveActivitiesService: Error initializing: $e'); + _isSupported = false; + _isInitialized = true; + return false; + } + } + + /// Request permission for Live Activities + Future _requestLiveActivitiesPermission() async { + try { + // Check if permission is already granted + bool alreadyGranted = await _liveActivities.areActivitiesEnabled(); + if (alreadyGranted) { + debugPrint('LiveActivitiesService: Permission already granted'); + return true; + } + + // iOS 16.1+ requires the user to explicitly enable Live Activities + // There's no direct permission API, the user must enable it in Settings + // We can only check if it's enabled, not request it directly + debugPrint( + 'LiveActivitiesService: Checking Live Activities availability'); + final isEnabled = await _liveActivities.areActivitiesEnabled(); + + if (!isEnabled) { + debugPrint( + 'LiveActivitiesService: Live Activities not enabled. User needs to enable in Settings'); + } + + return isEnabled; + } catch (e) { + debugPrint('LiveActivitiesService: Error checking permission: $e'); + return false; + } + } + + /// Get appGroupId safely, ensuring it's available + String? _getAppGroupId() { + if (_appGroupId == null) { + debugPrint( + 'LiveActivitiesService: Warning - appGroupId is null. Please call init() first.'); + debugPrint( + 'LiveActivitiesService: Current state: initialized=$_isInitialized, supported=$_isSupported'); + } + return _appGroupId; + } + + /// Helper method to create a search activity + Future createSearchActivity({ + required String title, + required String query, + required String count, + }) async { + if (!_isInitialized) await initialize(); + if (!_isSupported) return null; + + try { + // Use instance appGroupId + final String? appGroupId = _getAppGroupId(); + + // Verify appGroupId is set + if (appGroupId == null) { + throw Exception('appGroupId is null. Please call init() first.'); + } + + // Always reinitialize with the current app group ID to ensure it's set correctly + await _liveActivities.init(appGroupId: appGroupId); + debugPrint( + 'LiveActivitiesService: Ensuring plugin initialized with appGroupId: $appGroupId'); + + debugPrint( + 'LiveActivitiesService: Creating activity with appGroupId: $appGroupId'); + + // Create data for the activity + final Map activityData = { + 'title': title, + 'query': query, + 'resultsCount': count, + 'timestamp': DateTime.now().toIso8601String(), + 'appGroup': appGroupId, + }; + + // Add urlScheme if available + if (_urlScheme != null) { + activityData['urlScheme'] = _urlScheme; + } + + debugPrint('LiveActivitiesService: Activity data: $activityData'); + + // Create the activity + final activityId = await _liveActivities.createActivity(activityData); + + debugPrint( + 'LiveActivitiesService: Created activity with ID: $activityId'); + + // Get current active activities count + final activities = await _liveActivities.getAllActivitiesIds(); + debugPrint( + 'LiveActivitiesService: Active activities count: ${activities?.length ?? 0}'); + + return activityId; + } catch (e) { + debugPrint('LiveActivitiesService: Error creating activity: $e'); + return null; + } + } + + /// Update an existing search activity + Future updateSearchActivity({ + required String activityId, + required String title, + required String query, + required String count, + }) async { + if (!_isInitialized) await initialize(); + if (!_isSupported) return false; + + try { + // Use instance appGroupId + final String? appGroupId = _getAppGroupId(); + + // Verify appGroupId is set + if (appGroupId == null) { + throw Exception('appGroupId is null. Please call init() first.'); + } + + // Always reinitialize with the current app group ID to ensure it's set correctly + await _liveActivities.init(appGroupId: appGroupId); + debugPrint( + 'LiveActivitiesService: Ensuring plugin initialized with appGroupId: $appGroupId'); + + // Create updated data for the activity + final Map activityData = { + 'title': title, + 'query': query, + 'resultsCount': count, + 'timestamp': DateTime.now().toIso8601String(), + 'appGroup': appGroupId, + }; + + // Add urlScheme if available + if (_urlScheme != null) { + activityData['urlScheme'] = _urlScheme; + } + + // Update the activity + final success = + await _liveActivities.updateActivity(activityId, activityData); + + debugPrint( + 'LiveActivitiesService: Updated activity $activityId: $success'); + return success ?? false; + } catch (e) { + debugPrint('LiveActivitiesService: Error updating activity: $e'); + return false; + } + } + + /// End an existing search activity + Future endSearchActivity(String activityId) async { + if (!_isInitialized) await initialize(); + if (!_isSupported) return false; + + try { + // Get the appGroupId + final String? appGroupId = _getAppGroupId(); + + // Verify appGroupId is set + if (appGroupId == null) { + throw Exception('appGroupId is null. Please call init() first.'); + } + + // Always reinitialize with the current app group ID to ensure it's set correctly + await _liveActivities.init(appGroupId: appGroupId); + debugPrint( + 'LiveActivitiesService: Ensuring plugin initialized with appGroupId: $appGroupId'); + + debugPrint('LiveActivitiesService: Ending activity $activityId'); + + // End the activity + final success = await _liveActivities.endActivity(activityId); + + debugPrint('LiveActivitiesService: Ended activity $activityId: $success'); + return success ?? false; + } catch (e) { + debugPrint('LiveActivitiesService: Error ending activity: $e'); + return false; + } + } + + /// Get all active activities + Future?> getActiveActivities() async { + if (!_isInitialized) await initialize(); + if (!_isSupported) return null; + + try { + // Get the appGroupId + final String? appGroupId = _getAppGroupId(); + + // Verify appGroupId is set + if (appGroupId == null) { + throw Exception('appGroupId is null. Please call init() first.'); + } + + // Always reinitialize with the current app group ID to ensure it's set correctly + await _liveActivities.init(appGroupId: appGroupId); + debugPrint( + 'LiveActivitiesService: Ensuring plugin initialized with appGroupId: $appGroupId'); + + final activities = await _liveActivities.getAllActivitiesIds(); + debugPrint( + 'LiveActivitiesService: Found ${activities?.length ?? 0} active activities'); + return activities; + } catch (e) { + debugPrint('LiveActivitiesService: Error getting active activities: $e'); + return null; + } + } + + /// End all active activities + Future endAllActivities() async { + if (!_isInitialized) await initialize(); + if (!_isSupported) return false; + + try { + // Get the appGroupId + final String? appGroupId = _getAppGroupId(); + + // Verify appGroupId is set + if (appGroupId == null) { + throw Exception('appGroupId is null. Please call init() first.'); + } + + // Always reinitialize with the current app group ID to ensure it's set correctly + await _liveActivities.init(appGroupId: appGroupId); + debugPrint( + 'LiveActivitiesService: Ensuring plugin initialized with appGroupId: $appGroupId'); + + // First try using the native plugin method if available + final success = await _liveActivities.endAllActivities(); + if (success == true) { + debugPrint( + 'LiveActivitiesService: Ended all activities using plugin method'); + return true; + } + + // Fallback to manual method if plugin doesn't support endAllActivities + final activities = await getActiveActivities(); + if (activities == null || activities.isEmpty) { + debugPrint('LiveActivitiesService: No active activities to end'); + return true; + } + + bool allEnded = true; + for (final activityId in activities) { + final ended = await endSearchActivity(activityId); + if (!ended) { + allEnded = false; + debugPrint( + 'LiveActivitiesService: Failed to end activity $activityId'); + } + } + + debugPrint( + 'LiveActivitiesService: Ended ${activities.length} activities manually'); + return allEnded; + } catch (e) { + debugPrint('LiveActivitiesService: Error ending all activities: $e'); + return false; + } + } + + /// Request notification permission if needed + Future requestNotificationPermission() async { + if (!Platform.isIOS) return false; + + try { + debugPrint('LiveActivitiesService: Requesting notification permission'); + // The LiveActivities plugin doesn't have a direct method to request notification permission + // This is usually handled by the main app's notification system + return true; + } catch (e) { + debugPrint( + 'LiveActivitiesService: Error requesting notification permission: $e'); + return false; + } + } + + /// Check if Live Activities are supported on this device + bool get isSupported => _isSupported; + + /// Check if Live Activities are initialized + bool get isInitialized => _isInitialized; + + /// Get the current appGroupId + String? get appGroupId => _appGroupId; +} diff --git a/lib/services/mimir_service.dart b/lib/services/mimir_service.dart new file mode 100644 index 0000000..a6590e4 --- /dev/null +++ b/lib/services/mimir_service.dart @@ -0,0 +1,343 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_mimir/flutter_mimir.dart'; +import 'package:search_engine/database.dart'; +import 'package:flutter/foundation.dart'; + +typedef ProgressCallback = void Function(String message, double progress); + +class MimirService { + static final MimirService _instance = MimirService._internal(); + factory MimirService() => _instance; + MimirService._internal(); + + late MimirIndex _index; + bool _isInitialized = false; + + Future initialize() async { + if (_isInitialized) return; + + final instance = await Mimir.defaultInstance; + _index = instance.getIndex('messages'); + _isInitialized = true; + } + + Future getDocumentCount() async { + if (!_isInitialized) await initialize(); + final stats = await _index.getAllDocuments(); + return stats.length; + } + + Future>> getAllDocuments() async { + if (!_isInitialized) await initialize(); + return await _index.getAllDocuments(); + } + + Future addDocument( + String id, String languagesCode, String content) async { + if (!_isInitialized) await initialize(); + + await _index.addDocument({ + 'id': id, + 'languages_code': languagesCode, + 'content': content, + }); + } + + Future addDocuments(List> documents) async { + if (!_isInitialized) await initialize(); + + await _index.addDocuments(documents); + } + + Future> search(String query, String languagesCode, + {int limit = 1000, int offset = 0}) async { + if (!_isInitialized) await initialize(); + + // Execute the search without pagination to get ALL matching results + final allResults = await _index.search( + query: query, + filter: Mimir.where('languages_code', isEqualTo: languagesCode), + ); + + if (kDebugMode && allResults.isNotEmpty) { + print('🔍 Mimir encontró ${allResults.length} resultados totales'); + } + + // Apply pagination AFTER getting all results + final start = offset; + final end = offset + limit; + final paginatedResults = allResults.sublist( + start.clamp(0, allResults.length), + end.clamp(0, allResults.length), + ); + + // Transform results to include both id and content snippet + final transformedResults = paginatedResults.map((doc) { + final String content = doc['content'] as String; + // Extract a snippet of content and position information + final Map snippetInfo = + _extractSnippetWithPosition(content, query); + + return { + 'id': doc['id'] as String, + 'content': snippetInfo['snippet'] as String, + 'position': snippetInfo['position'].toString(), + 'length': snippetInfo['length'].toString(), + }; + }).toList(); + + // Return both the paginated results and the total count + return { + 'results': transformedResults, + 'total': allResults.length, + 'allResultIds': allResults.map((doc) => doc['id'] as String).toList(), + }; + } + + // Helper method to extract a relevant snippet from content with position information + Map _extractSnippetWithPosition( + String content, String query) { + // Clean HTML tags and entities first + String cleanContent = _cleanHtmlContent(content); + + // Default values + int snippetPosition = 0; + int matchLength = 0; + + // If content is short enough, return it all + if (cleanContent.length <= 300) { + return { + 'snippet': cleanContent, + 'position': 0, + 'length': cleanContent.length + }; + } + + // Try to find the query in the content + final lowerContent = cleanContent.toLowerCase(); + final lowerQuery = query.toLowerCase(); + final int position = lowerContent.indexOf(lowerQuery); + + if (position != -1) { + // Found the query, extract a snippet around it + final int start = (position - 100).clamp(0, cleanContent.length); + final int end = + (position + query.length + 100).clamp(0, cleanContent.length); + + String snippet = cleanContent.substring(start, end); + + // Add ellipsis if needed + if (start > 0) snippet = '...$snippet'; + if (end < cleanContent.length) snippet = '$snippet...'; + + // Calculate relative position in the snippet + snippetPosition = position - start; + if (start > 0) snippetPosition += 3; // Adjust for ellipsis + matchLength = query.length; + + return { + 'snippet': snippet, + 'position': snippetPosition, + 'length': matchLength + }; + } else { + // Query not found directly, try with individual words + final List queryWords = + query.split(' ').where((word) => word.trim().length > 2).toList(); + + for (final word in queryWords) { + final int wordPos = lowerContent.indexOf(word.toLowerCase()); + if (wordPos != -1) { + final int start = (wordPos - 100).clamp(0, cleanContent.length); + final int end = + (wordPos + word.length + 100).clamp(0, cleanContent.length); + + String snippet = cleanContent.substring(start, end); + + if (start > 0) snippet = '...$snippet'; + if (end < cleanContent.length) snippet = '$snippet...'; + + // Calculate relative position in the snippet + snippetPosition = wordPos - start; + if (start > 0) snippetPosition += 3; // Adjust for ellipsis + matchLength = word.length; + + return { + 'snippet': snippet, + 'position': snippetPosition, + 'length': matchLength + }; + } + } + + // No match found, return the beginning of the content + return { + 'snippet': '${cleanContent.substring(0, 300)}...', + 'position': 0, + 'length': 0 + }; + } + } + + // Helper method to extract a snippet without position information (legacy) + String _extractSnippet(String content, String query) { + return _extractSnippetWithPosition(content, query)['snippet'] as String; + } + + // Helper method to clean HTML content using regex + String _cleanHtmlContent(String html) { + if (html.isEmpty) return ''; + + // Step 1: Remove HTML tags + String result = html.replaceAll(RegExp(r'<[^>]*>'), ' '); + + // Step 2: Replace common HTML entities + final Map htmlEntities = { + ' ': ' ', + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + '¢': '¢', + '£': '£', + '¥': '¥', + '€': '€', + '©': '©', + '®': '®', + 'á': 'á', + 'é': 'é', + 'í': 'í', + 'ó': 'ó', + 'ú': 'ú', + 'ñ': 'ñ', + 'Á': 'Á', + 'É': 'É', + 'Í': 'Í', + 'Ó': 'Ó', + 'Ú': 'Ú', + 'Ñ': 'Ñ', + }; + + // Replace known HTML entities + htmlEntities.forEach((entity, replacement) { + result = result.replaceAll(entity, replacement); + }); + + // Step 3: Replace numeric HTML entities (like {) + result = result.replaceAllMapped(RegExp(r'&#(\d+);'), (match) { + try { + final int charCode = int.parse(match.group(1)!); + return String.fromCharCode(charCode); + } catch (e) { + return ''; + } + }); + + // Step 4: Replace any remaining entities with a generic pattern + result = result.replaceAll(RegExp(r'&[a-zA-Z0-9]+;'), ''); + + // Step 5: Normalize whitespace (replace multiple spaces with a single space) + result = result.replaceAll(RegExp(r'\s+'), ' ').trim(); + + return result; + } + + Future deleteDocument(String id) async { + if (!_isInitialized) await initialize(); + + await _index.deleteDocument(id); + } + + Future clearIndex() async { + if (!_isInitialized) await initialize(); + + await _index.deleteAllDocuments(); + } + + Future syncWithDatabase({ProgressCallback? onProgress}) async { + if (!_isInitialized) await initialize(); + + final database = AppDatabase(); + try { + // Get all documents from Mimir + final mimirDocuments = await getAllDocuments(); + final mimirCount = mimirDocuments.length; + + // Create a set of document IDs that are already in Mimir + final mimirIds = + Set.from(mimirDocuments.map((doc) => doc['id'] as String)); + + // Get all messages from database + final messages = await database.getAllMessages(); + final dbCount = messages.length; + + if (kDebugMode) { + print('📊 Documentos en Mimir: $mimirCount'); + print('📊 Documentos en Base de Datos: $dbCount'); + } + + // Find documents that need to be added (in DB but not in Mimir) + final documentsToAdd = messages.where((draft) { + return !mimirIds.contains(draft.id) && + draft.body != null && + draft.body!.isNotEmpty; + }).toList(); + + if (documentsToAdd.isNotEmpty) { + if (kDebugMode) { + print('🔄 Documentos a agregar a Mimir: ${documentsToAdd.length}'); + } + + onProgress?.call('updating_search_index'.tr(), 0.0); + + // Prepare documents for Mimir + final documents = documentsToAdd.map((draft) { + return { + 'id': draft.id, + 'languages_code': draft.languagesCode, + 'content': draft.body!, + }; + }).toList(); + + if (kDebugMode) { + print('📝 Documentos válidos para indexar: ${documents.length}'); + } + + // Add documents to Mimir in batches + const batchSize = 50; + var indexedCount = 0; + for (var i = 0; i < documents.length; i += batchSize) { + final end = (i + batchSize < documents.length) + ? i + batchSize + : documents.length; + final batch = documents.sublist(i, end); + await addDocuments(batch); + indexedCount += batch.length; + + // Update progress + final progress = indexedCount / documents.length; + onProgress?.call('updating_search_index'.tr(), progress); + + if (kDebugMode) { + print( + '✓ Progreso de indexación: $indexedCount/${documents.length}'); + } + } + + if (kDebugMode) { + final finalCount = await getDocumentCount(); + print('✅ Indexación completada. Documentos en Mimir: $finalCount'); + } + + onProgress?.call('search_index_updated'.tr(), 1.0); + } else { + if (kDebugMode) { + print('✅ Mimir y Base de Datos están sincronizados'); + } + } + } finally { + database.close(); + } + } +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart new file mode 100644 index 0000000..6560d95 --- /dev/null +++ b/lib/services/notification_service.dart @@ -0,0 +1,198 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:search_engine/services/live_activities_service.dart'; + +class NotificationService { + static final NotificationService _instance = NotificationService._internal(); + factory NotificationService() => _instance; + NotificationService._internal(); + + final FlutterLocalNotificationsPlugin _notifications = + FlutterLocalNotificationsPlugin(); + bool _isLiveActivitiesSupported = false; + bool _isLiveActivitiesInitialized = false; + + // Variables para manejo condicional de LiveActivities + dynamic _liveActivities; + + // Method to create LiveActivities instance + dynamic createLiveActivitiesInstance() { + if (Platform.isIOS) { + return LiveActivitiesService(); + } + return null; + } + + Future initialize() async { + // Inicializar notificaciones normales + const initializationSettingsAndroid = + AndroidInitializationSettings('@mipmap/ic_launcher'); + const initializationSettingsIOS = DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + ); + const initializationSettings = InitializationSettings( + android: initializationSettingsAndroid, + iOS: initializationSettingsIOS, + ); + await _notifications.initialize(initializationSettings); + + // Inicializar Live Activities solo en iOS + if (Platform.isIOS) { + try { + await _initializeLiveActivities(); + } catch (e) { + print('Error al inicializar Live Activities: $e'); + _isLiveActivitiesSupported = false; + } + } + } + + Future _initializeLiveActivities() async { + try { + // Intentamos crear una instancia de LiveActivities de forma segura + _liveActivities = createLiveActivitiesInstance(); + + if (_liveActivities != null) { + try { + await _liveActivities.init( + appGroupId: + 'group.com.lgcc.search', // Reemplazar con tu App Group ID + urlScheme: 'lgcc' // Reemplazar con tu URL scheme + ); + + // Verificamos si están habilitadas + _isLiveActivitiesSupported = + await _liveActivities.areActivitiesEnabled(); + _isLiveActivitiesInitialized = true; + + print('LiveActivities inicializadas: $_isLiveActivitiesSupported'); + } catch (e) { + print('Error al inicializar LiveActivities: $e'); + _isLiveActivitiesSupported = false; + _isLiveActivitiesInitialized = false; + } + } else { + print('LiveActivities no disponible en este dispositivo'); + _isLiveActivitiesSupported = false; + _isLiveActivitiesInitialized = false; + } + } catch (e) { + print('Error al crear instancia de LiveActivities: $e'); + _isLiveActivitiesSupported = false; + _isLiveActivitiesInitialized = false; + } + } + + Future showSearchNotification({ + required String title, + required String body, + String? searchTerm, + int? totalResults, + }) async { + if (_isLiveActivitiesSupported && + _isLiveActivitiesInitialized && + Platform.isIOS && + _liveActivities != null) { + // Usar Live Activities en iOS 16.1+ + try { + final Map activityModel = { + 'title': title, + 'body': body, + 'searchTerm': searchTerm ?? '', + 'totalResults': totalResults ?? 0, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + }; + + final activityId = await _liveActivities.createActivity(activityModel); + print('LiveActivity creada con éxito: $activityId'); + return; + } catch (e) { + print('Error al crear LiveActivity: $e'); + // Si falla, usamos notificación estándar como fallback + } + } + + // Usar notificación normal en Android o iOS < 16.1 o si falló Live Activities + await _showNormalNotification(title: title, body: body); + } + + Future _showNormalNotification({ + required String title, + required String body, + }) async { + const androidDetails = AndroidNotificationDetails( + 'search_channel', + 'Search Notifications', + channelDescription: 'Notifications for search results', + importance: Importance.high, + priority: Priority.high, + ); + + const iosDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ); + + const details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _notifications.show( + DateTime.now().millisecond, + title, + body, + details, + ); + + print('Notificación estándar mostrada'); + } + + Future updateSearchActivity({ + required String activityId, + required Map data, + }) async { + if (_isLiveActivitiesSupported && + _isLiveActivitiesInitialized && + Platform.isIOS && + _liveActivities != null) { + try { + await _liveActivities.updateActivity(activityId, data); + print('LiveActivity actualizada: $activityId'); + } catch (e) { + print('Error al actualizar LiveActivity: $e'); + } + } + } + + Future endSearchActivity(String activityId) async { + if (_isLiveActivitiesSupported && + _isLiveActivitiesInitialized && + Platform.isIOS && + _liveActivities != null) { + try { + await _liveActivities.endActivity(activityId); + print('LiveActivity finalizada: $activityId'); + } catch (e) { + print('Error al finalizar LiveActivity: $e'); + } + } + } + + void dispose() async { + if (_isLiveActivitiesSupported && + _isLiveActivitiesInitialized && + Platform.isIOS && + _liveActivities != null) { + try { + await _liveActivities.dispose(); + } catch (e) { + print('Error al liberar LiveActivities: $e'); + } + } + } +} diff --git a/lib/utils.dart b/lib/utils.dart new file mode 100644 index 0000000..5d1bff5 --- /dev/null +++ b/lib/utils.dart @@ -0,0 +1,15 @@ +import 'package:easy_localization/easy_localization.dart'; + +String formatDate(DateTime date, String locale) { + final formatter = + DateFormat('EEEE, d \'de\' MMMM \'del\' y', locale); + String formattedDate = formatter.format(date); + formattedDate = + formattedDate.replaceRange(0, 1, formattedDate[0].toUpperCase()); + formattedDate = formattedDate.replaceRange( + formattedDate.indexOf(' de ') + 4, + formattedDate.indexOf(' de ') + 5, + formattedDate[formattedDate.indexOf(' de ') + 4].toUpperCase()); + + return formattedDate; +} diff --git a/lib/widgets/base.dart b/lib/widgets/base.dart new file mode 100644 index 0000000..7797800 --- /dev/null +++ b/lib/widgets/base.dart @@ -0,0 +1,315 @@ +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:search_engine/screens/config.dart'; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart' + as nav; + +class BaseScreen extends StatelessWidget { + final Widget child; + final String? title; + final bool showSearchBar; + final bool showSettingsButton; + final bool showUserAvatar; + final TextEditingController? searchController; + final Function(String)? onSearchChanged; + final Function(String)? onSearchSubmitted; + final String? searchHintText; + final Widget? searchSuffixIcon; + final EdgeInsetsGeometry contentPadding; + final Widget? drawer; + final bool returnButton; + + const BaseScreen({ + super.key, + required this.child, + this.title, + this.showSearchBar = false, + this.showSettingsButton = false, + this.showUserAvatar = false, + this.searchController, + this.onSearchChanged, + this.onSearchSubmitted, + this.searchHintText, + this.searchSuffixIcon, + this.contentPadding = const EdgeInsets.all(20.0), + this.drawer, + this.returnButton = false, + }); + + @override + Widget build(BuildContext context) { + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; + + return Scaffold( + drawer: drawer, + drawerEnableOpenDragGesture: true, + drawerEdgeDragWidth: 50, + onDrawerChanged: (isOpened) {}, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Layout adaptativo según la orientación + if (isLandscape) + _buildLandscapeHeader(context) + else + _buildPortraitHeader(context), + + // Content area + Expanded( + child: child, + ), + ], + ), + ), + ); + } + + // Layout para orientación horizontal (landscape) + Widget _buildLandscapeHeader(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + child: Row( + children: [ + // Navigation icon (back button or drawer) + if (returnButton) + IconButton( + icon: const Icon(Icons.arrow_back, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + visualDensity: VisualDensity.compact, + ) + else if (drawer != null) + IconButton( + icon: const Icon(Icons.menu, size: 20), + onPressed: () => Scaffold.of(context).openDrawer(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + visualDensity: VisualDensity.compact, + ), + + // Title + if (title != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + title!, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + + const SizedBox(width: 8), + + // Search bar inline + if (showSearchBar && searchController != null) + Expanded( + child: SizedBox( + height: 34, + child: TextFormField( + decoration: InputDecoration( + fillColor: const Color(0xFFEBEBEB), + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + prefixIcon: + const Icon(Icons.search, color: Colors.grey, size: 18), + hintText: searchHintText ?? 'search_placeholder'.tr(), + hintStyle: const TextStyle( + color: Colors.grey, + fontSize: 14, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + isDense: true, + suffixIcon: searchController?.text.isNotEmpty ?? false + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + searchController?.clear(); + onSearchChanged?.call(''); + }, + ) + : null, + ), + controller: searchController, + onChanged: onSearchChanged, + onFieldSubmitted: onSearchSubmitted, + autofocus: false, + autocorrect: true, + style: const TextStyle(fontSize: 14), + ), + ), + ), + + // Settings button + if (showSettingsButton) + IconButton( + onPressed: () => + nav.pushScreenWithoutNavBar(context, const ConfigView()), + icon: const Icon(Icons.settings, size: 20), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + visualDensity: VisualDensity.compact, + ), + + // User avatar + if (showUserAvatar) + const Padding( + padding: EdgeInsets.only(left: 4), + child: CircleAvatar( + backgroundColor: Colors.grey, + radius: 12, + ), + ), + ], + ), + ); + } + + // Layout para orientación vertical (portrait) + Widget _buildPortraitHeader(BuildContext context) { + return Column( + children: [ + // Header row with title and buttons + Padding( + padding: const EdgeInsets.only( + left: 12.0, + right: 12.0, + top: 6.0, + bottom: 4.0, + ), + child: Row( + children: [ + // Navigation icon (back button or drawer) + if (returnButton) + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + iconSize: 20, + ) + else if (drawer != null) + IconButton( + icon: const Icon(Icons.menu), + onPressed: () => Scaffold.of(context).openDrawer(), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + iconSize: 20, + ), + + // Title + if (title != null) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + title!, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + + // Settings button + if (showSettingsButton) + IconButton( + onPressed: () => + nav.pushScreenWithoutNavBar(context, const ConfigView()), + icon: const Icon(Icons.settings, size: 20), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + visualDensity: VisualDensity.compact, + ), + + // User avatar + if (showUserAvatar) + const Padding( + padding: EdgeInsets.only(left: 4), + child: CircleAvatar( + backgroundColor: Colors.grey, + radius: 12, + ), + ), + ], + ), + ), + + // Search bar in a separate row + if (showSearchBar && searchController != null) + Padding( + padding: const EdgeInsets.only( + left: 12.0, + right: 12.0, + bottom: 6.0, + ), + child: SizedBox( + height: 34, + child: TextFormField( + decoration: InputDecoration( + fillColor: const Color(0xFFEBEBEB), + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + prefixIcon: + const Icon(Icons.search, color: Colors.grey, size: 18), + hintText: searchHintText ?? 'search_placeholder'.tr(), + hintStyle: const TextStyle( + color: Colors.grey, + fontSize: 14, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + isDense: true, + suffixIcon: searchController?.text.isNotEmpty ?? false + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + searchController?.clear(); + onSearchChanged?.call(''); + }, + ) + : null, + ), + controller: searchController, + onChanged: onSearchChanged, + onFieldSubmitted: onSearchSubmitted, + autofocus: false, + autocorrect: true, + style: const TextStyle(fontSize: 14), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/navigation_bar.dart b/lib/widgets/navigation_bar.dart new file mode 100644 index 0000000..6f66bdc --- /dev/null +++ b/lib/widgets/navigation_bar.dart @@ -0,0 +1,78 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:search_engine/screens/home.dart'; +import 'package:search_engine/screens/search.dart'; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart' + as nav; + +class GlobalNavigator extends StatefulWidget { + const GlobalNavigator({super.key}); + + @override + _GlobalNavigatorState createState() => _GlobalNavigatorState(); + + static void navigateToIndex(BuildContext context, int index) { + final navigatorState = + context.findAncestorStateOfType<_GlobalNavigatorState>(); + navigatorState?._navigateToIndex(index); + } +} + +class _GlobalNavigatorState extends State { + late nav.PersistentTabController _controller; + + @override + void initState() { + super.initState(); + _controller = nav.PersistentTabController(initialIndex: 0); + } + + List _navBarsItems() { + return [ + nav.PersistentTabConfig( + screen: const SearchPage(), + item: nav.ItemConfig( + icon: const Icon(Icons.search), + title: "search".tr(), + activeForegroundColor: const Color(0xFF871818), + inactiveForegroundColor: Colors.grey, + )), + nav.PersistentTabConfig( + screen: const HomePage(), + item: nav.ItemConfig( + icon: const Icon(Icons.book), + title: "title".tr(), + activeForegroundColor: const Color(0XFF6b8e23), + inactiveForegroundColor: Colors.grey, + )) + ]; + } + + void _navigateToIndex(int index) { + if (mounted) { + setState(() { + _controller.jumpToTab(index); + }); + } + } + + @override + Widget build(BuildContext context) { + return nav.PersistentTabView( + navBarBuilder: (navBarConfig) => nav.Style2BottomNavBar( + navBarConfig: navBarConfig, + navBarDecoration: nav.NavBarDecoration(color: Colors.transparent), + ), + controller: _controller, + tabs: _navBarsItems(), + backgroundColor: Colors.white, + handleAndroidBackButtonPress: true, + resizeToAvoidBottomInset: true, + gestureNavigationEnabled: true, + stateManagement: true, + screenTransitionAnimation: nav.ScreenTransitionAnimation( + curve: Easing.emphasizedDecelerate, + duration: Duration(milliseconds: 150)), + ); + } +} diff --git a/linux/main.cc b/linux/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/my_application.cc b/linux/my_application.cc new file mode 100644 index 0000000..75cb3ca --- /dev/null +++ b/linux/my_application.cc @@ -0,0 +1,124 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "Estudios bíblicos"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "Estudios bíblicos"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/my_application.h b/linux/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..5413b12 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,43 @@ +platform :osx, '11.14' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..ab7dcad --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,146 @@ +PODS: + - audio_session (0.0.1): + - FlutterMacOS + - country_codes (0.0.1): + - FlutterMacOS + - device_info_plus (0.0.1): + - FlutterMacOS + - flutter_local_notifications (0.0.1): + - FlutterMacOS + - flutter_mimir (0.0.1) + - FlutterMacOS (1.0.0) + - gal (1.0.0): + - Flutter + - FlutterMacOS + - just_audio (0.0.1): + - Flutter + - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - sqlite3 (3.49.1): + - sqlite3/common (= 3.49.1) + - sqlite3/common (3.49.1) + - sqlite3/dbstatvtab (3.49.1): + - sqlite3/common + - sqlite3/fts5 (3.49.1): + - sqlite3/common + - sqlite3/perf-threadsafe (3.49.1): + - sqlite3/common + - sqlite3/rtree (3.49.1): + - sqlite3/common + - sqlite3_flutter_libs (0.0.1): + - Flutter + - FlutterMacOS + - sqlite3 (~> 3.49.1) + - sqlite3/dbstatvtab + - sqlite3/fts5 + - sqlite3/perf-threadsafe + - sqlite3/rtree + - syncfusion_pdfviewer_macos (0.0.1): + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS + - wakelock_plus (0.0.1): + - FlutterMacOS + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) + - country_codes (from `Flutter/ephemeral/.symlinks/plugins/country_codes/macos`) + - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) + - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) + - flutter_mimir (from `Flutter/ephemeral/.symlinks/plugins/flutter_mimir/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`) + - just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) + - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`) + - syncfusion_pdfviewer_macos (from `Flutter/ephemeral/.symlinks/plugins/syncfusion_pdfviewer_macos/macos`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`) + - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) + - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) + +SPEC REPOS: + trunk: + - sqlite3 + +EXTERNAL SOURCES: + audio_session: + :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos + country_codes: + :path: Flutter/ephemeral/.symlinks/plugins/country_codes/macos + device_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos + flutter_local_notifications: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos + flutter_mimir: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_mimir/macos + FlutterMacOS: + :path: Flutter/ephemeral + gal: + :path: Flutter/ephemeral/.symlinks/plugins/gal/darwin + just_audio: + :path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin + sqlite3_flutter_libs: + :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin + syncfusion_pdfviewer_macos: + :path: Flutter/ephemeral/.symlinks/plugins/syncfusion_pdfviewer_macos/macos + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + video_player_avfoundation: + :path: Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin + wakelock_plus: + :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos + webview_flutter_wkwebview: + :path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin + +SPEC CHECKSUMS: + audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e + country_codes: 5dfbeb7300cc873e3125c47806a3cc8f4b04009d + device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 + flutter_local_notifications: 13862b132e32eb858dea558a86d45d08daeacfe7 + flutter_mimir: abc5575f7deea72a2716fb3a75295984681dfd87 + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + gal: baecd024ebfd13c441269ca7404792a7152fde89 + just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 + sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241 + syncfusion_pdfviewer_macos: 94d2eafcb3475d32309d4ba282ef02dc75fd682f + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b + wakelock_plus: 21ddc249ac4b8d018838dbdabd65c5976c308497 + webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 + +PODFILE CHECKSUM: ebd4484585a0d7bc022aaecd743fb4a450f70847 + +COCOAPODS: 1.16.2 diff --git a/rest-gotify-jville.txt b/rest-gotify-jville.txt new file mode 100644 index 0000000..6c689bf --- /dev/null +++ b/rest-gotify-jville.txt @@ -0,0 +1 @@ +curl -X POST "https://gotify-production-34a7.up.railway.app/message?token=AUsFSONcW5Y2UHS" -F "title=Hola" -F "message=Este es un mensaje de prueba" -F "priority=5" \ No newline at end of file diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..7b8d2de --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:search_engine/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +}