diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6d21dfa..c69d0f0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -167,6 +167,9 @@ dependencies { // JSoup for HTML parsing (metadata extraction) implementation(libs.jsoup) + // Readability for better article extraction + implementation("net.dankito.readability4j:readability4j:1.0.8") + // Biometric implementation(libs.androidx.biometric) diff --git a/app/src/main/java/com/shaarit/data/reader/ArticleExtractor.kt b/app/src/main/java/com/shaarit/data/reader/ArticleExtractor.kt index f41d961..a30b7a7 100644 --- a/app/src/main/java/com/shaarit/data/reader/ArticleExtractor.kt +++ b/app/src/main/java/com/shaarit/data/reader/ArticleExtractor.kt @@ -7,6 +7,14 @@ import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element import org.jsoup.safety.Safelist +import net.dankito.readability4j.Readability4J +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.launch +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.delay +import kotlin.coroutines.resume import javax.inject.Inject import javax.inject.Singleton @@ -24,53 +32,19 @@ data class ReadableArticle( ) /** - * Extracteur d'articles style Readability basé sur JSoup. + * Extracteur d'articles basé sur Readability4J (portage de Mozilla Readability). * Extrait le contenu principal d'une page web en supprimant navigation, pubs, sidebars, etc. */ @Singleton -class ArticleExtractor @Inject constructor() { +class ArticleExtractor @Inject constructor( + @ApplicationContext private val context: Context +) { companion object { private const val TAG = "ArticleExtractor" private const val TIMEOUT_MS = 15000 private const val WORDS_PER_MINUTE = 200 - // Éléments à supprimer systématiquement - private val REMOVE_SELECTORS = listOf( - "script", "style", "noscript", "iframe", "object", "embed", - "nav", "header:not(article header)", "footer:not(article footer)", - ".sidebar", "#sidebar", ".widget", ".ad", ".ads", ".advert", - ".advertisement", "[class*=advert]", "[id*=advert]", - ".social-share", ".share-buttons", ".sharing", - ".comments", "#comments", ".comment-section", - ".related-posts", ".related-articles", ".recommended", - ".newsletter", ".subscribe", ".popup", ".modal", - ".cookie-banner", ".cookie-notice", ".gdpr", - ".breadcrumb", ".breadcrumbs", ".pagination", - ".menu", ".navigation", "#navigation", - "[role=navigation]", "[role=banner]", "[role=complementary]", - ".toc", "#toc", ".table-of-contents" - ) - - // Sélecteurs pour trouver le contenu principal (ordre de priorité) - private val CONTENT_SELECTORS = listOf( - "article", - "[role=main]", - "main", - ".post-content", - ".article-content", - ".entry-content", - ".content-body", - ".article-body", - ".post-body", - ".story-body", - "#article-body", - "#content", - ".content", - ".post", - ".article" - ) - // Safelist HTML permise dans le contenu nettoyé private val READER_SAFELIST = Safelist.relaxed() .addTags("figure", "figcaption", "picture", "source", "video", "audio") @@ -91,14 +65,98 @@ class ArticleExtractor @Inject constructor() { val doc = Jsoup.connect(url) .timeout(TIMEOUT_MS) .userAgent("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36") + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + .header("Accept-Language", "en-US,en;q=0.5") .followRedirects(true) .maxBodySize(5 * 1024 * 1024) // 5 MB max .get() - extractFromDocument(doc, url) + val article = extractFromDocument(doc, url) + if (article == null || article.wordCount < 50) { + // Si l'extraction JSoup échoue ou renvoie très peu de texte (ex: Cloudflare protection), + // on essaie avec WebView + Log.d(TAG, "JSoup n'a pas pu extraire assez de contenu, essai avec WebView...") + return@withContext extractWithWebView(url) + } + article } catch (e: Exception) { Log.e(TAG, "Erreur extraction article pour $url", e) - null + extractWithWebView(url) + } + } + + /** + * Fallback: Utilise une WebView cachée pour exécuter le JavaScript (contourne Cloudflare/SPAs) + */ + private suspend fun extractWithWebView(url: String): ReadableArticle? = withContext(Dispatchers.Main) { + suspendCancellableCoroutine { continuation -> + var isResumed = false + try { + val webView = android.webkit.WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.loadsImagesAutomatically = false // Pas besoin de charger les images pour le DOM + settings.userAgentString = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" + } + // Timeout global de 15 secondes + val timeoutJob = MainScope().launch { + delay(15000) + if (!isResumed && continuation.isActive) { + isResumed = true + webView.stopLoading() + webView.destroy() + continuation.resume(null) + } + } + + webView.webViewClient = object : android.webkit.WebViewClient() { + override fun onPageFinished(view: android.webkit.WebView?, pageUrl: String?) { + // Attendre un peu que le JS modifie le DOM (Cloudflare / React) + MainScope().launch { + delay(3000) + if (isResumed || !continuation.isActive) return@launch + + view?.evaluateJavascript( + "(function() { return document.documentElement.outerHTML; })();" + ) { html -> + if (isResumed || !continuation.isActive) return@evaluateJavascript + isResumed = true + timeoutJob.cancel() + + val unescaped = try { + if (html != null && html != "null") { + org.json.JSONTokener(html).nextValue() as? String ?: html + } else { + "" + } + } catch (e: Exception) { + html ?: "" + } + + val doc = Jsoup.parse(unescaped, url) + val article = extractFromDocument(doc, url) + + view?.destroy() + continuation.resume(article) + } + } + } + } + + webView.loadUrl(url) + + continuation.invokeOnCancellation { + timeoutJob.cancel() + if (!isResumed) { + webView.stopLoading() + webView.destroy() + } + } + } catch (e: Exception) { + if (continuation.isActive && !isResumed) { + continuation.resume(null) + } + } } } @@ -107,29 +165,27 @@ class ArticleExtractor @Inject constructor() { */ fun extractFromDocument(doc: Document, baseUrl: String): ReadableArticle? { return try { - // Extraire les métadonnées - val title = extractTitle(doc) - val author = extractAuthor(doc) + // Extraire les métadonnées avec JSoup (plus robuste que Readability pour le site_name/image) val siteName = extractSiteName(doc, baseUrl) val leadImage = extractLeadImage(doc, baseUrl) - // Nettoyer le document - val cleanDoc = doc.clone() - removeUnwantedElements(cleanDoc) + // Utiliser Readability4J pour extraire l'article principal + val readability = Readability4J(baseUrl, doc.html()) + val article = readability.parse() - // Trouver le contenu principal - val mainContent = findMainContent(cleanDoc) - ?: return null + if (article.content == null || article.textContent == null) { + return null + } // Nettoyer le HTML du contenu principal val cleanHtml = Jsoup.clean( - mainContent.html(), + article.content!!, baseUrl, READER_SAFELIST ) // Calculer les stats - val textContent = Jsoup.parse(cleanHtml).text() + val textContent = article.textContent!! val wordCount = textContent.split(Regex("\\s+")).filter { it.isNotBlank() }.size val readingTime = maxOf(1, wordCount / WORDS_PER_MINUTE) @@ -139,8 +195,8 @@ class ArticleExtractor @Inject constructor() { } ReadableArticle( - title = title ?: "Sans titre", - author = author, + title = article.title ?: extractTitle(doc) ?: "Sans titre", + author = article.byline ?: extractAuthor(doc), siteName = siteName, content = cleanHtml, leadImage = leadImage, @@ -214,110 +270,6 @@ class ArticleExtractor @Inject constructor() { return null } - private fun removeUnwantedElements(doc: Document) { - for (selector in REMOVE_SELECTORS) { - try { - doc.select(selector).remove() - } catch (_: Exception) { - // Ignorer les erreurs de sélecteur - } - } - } - - /** - * Trouve le contenu principal en utilisant des heuristiques. - * Essaie d'abord les sélecteurs connus, puis scoring par densité de texte. - */ - private fun findMainContent(doc: Document): Element? { - // 1. Essayer les sélecteurs connus - for (selector in CONTENT_SELECTORS) { - val candidates = doc.select(selector) - if (candidates.isNotEmpty()) { - // Prendre le candidat avec le plus de texte - val best = candidates.maxByOrNull { it.text().length } - if (best != null && best.text().length > 200) { - return best - } - } - } - - // 2. Scoring par densité de texte sur les
et
- val candidates = doc.select("div, section") - if (candidates.isEmpty()) return doc.body() - - var bestElement: Element? = null - var bestScore = 0.0 - - for (element in candidates) { - val score = scoreElement(element) - if (score > bestScore) { - bestScore = score - bestElement = element - } - } - - return bestElement ?: doc.body() - } - - /** - * Score un élément selon sa probabilité de contenir le contenu principal. - * Inspiré de l'algorithme Readability de Mozilla. - */ - private fun scoreElement(element: Element): Double { - var score = 0.0 - - // Texte direct (pas dans les enfants) - val text = element.ownText() - val textLength = text.length - - // Plus de texte = plus probable - score += textLength * 0.1 - - // Nombre de paragraphes - val paragraphs = element.select("> p, > div > p") - score += paragraphs.size * 10.0 - - // Nombre de balises de contenu (images, code, etc.) - score += element.select("img").size * 3.0 - score += element.select("pre, code").size * 5.0 - score += element.select("blockquote").size * 3.0 - score += element.select("h2, h3, h4").size * 5.0 - - // Pénalité pour les liens (haute densité = probablement navigation) - val links = element.select("a") - val linkTextLength = links.sumOf { it.text().length } - val totalTextLength = element.text().length - if (totalTextLength > 0) { - val linkDensity = linkTextLength.toDouble() / totalTextLength - if (linkDensity > 0.5) { - score *= 0.2 // Forte pénalité - } else if (linkDensity > 0.3) { - score *= 0.5 - } - } - - // Pénalité pour les éléments trop courts - if (totalTextLength < 100) { - score *= 0.1 - } - - // Bonus pour les classes/ids évocateurs - val classId = "${element.className()} ${element.id()}".lowercase() - if (classId.contains("article") || classId.contains("content") || - classId.contains("post") || classId.contains("entry") || - classId.contains("text") || classId.contains("body")) { - score *= 1.5 - } - if (classId.contains("comment") || classId.contains("sidebar") || - classId.contains("footer") || classId.contains("header") || - classId.contains("nav") || classId.contains("menu") || - classId.contains("ad") || classId.contains("widget")) { - score *= 0.2 - } - - return score - } - private fun resolveUrl(url: String, baseUrl: String): String { return when { url.startsWith("http") -> url diff --git a/app/src/main/java/com/shaarit/presentation/browser/InternalBrowserScreen.kt b/app/src/main/java/com/shaarit/presentation/browser/InternalBrowserScreen.kt new file mode 100644 index 0000000..0193ffb --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/browser/InternalBrowserScreen.kt @@ -0,0 +1,136 @@ +package com.shaarit.presentation.browser + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.viewinterop.AndroidView +import java.net.URLDecoder + +@OptIn(ExperimentalMaterial3Api::class) +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun InternalBrowserScreen( + encodedUrl: String, + onNavigateBack: () -> Unit +) { + val url = remember(encodedUrl) { URLDecoder.decode(encodedUrl, "UTF-8") } + var pageTitle by remember { mutableStateOf(url) } + var isLoading by remember { mutableStateOf(true) } + var progress by remember { mutableFloatStateOf(0f) } + var webView by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + text = pageTitle, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.Close, contentDescription = "Fermer") + } + }, + actions = { + IconButton( + onClick = { + if (webView?.canGoBack() == true) { + webView?.goBack() + } + }, + enabled = webView?.canGoBack() == true + ) { + Icon(Icons.Default.ArrowBack, contentDescription = "Retour") + } + IconButton(onClick = { webView?.reload() }) { + Icon(Icons.Default.Refresh, contentDescription = "Actualiser") + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + AnimatedVisibility(visible = isLoading) { + LinearProgressIndicator( + progress = progress, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary + ) + } + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.loadsImagesAutomatically = true + + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + isLoading = true + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + isLoading = false + view?.title?.let { if (it.isNotBlank()) pageTitle = it } + } + } + + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + super.onProgressChanged(view, newProgress) + progress = newProgress / 100f + } + + override fun onReceivedTitle(view: WebView?, title: String?) { + super.onReceivedTitle(view, title) + if (!title.isNullOrBlank()) { + pageTitle = title + } + } + } + + loadUrl(url) + webView = this + } + }, + update = { + webView = it + } + ) + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksScreen.kt b/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksScreen.kt index 83d48d9..8d184ce 100644 --- a/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksScreen.kt @@ -14,6 +14,9 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Link +import androidx.compose.foundation.clickable import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -43,6 +46,8 @@ fun DeadLinksScreen( val selectedLinkIds by viewModel.selectedLinkIds.collectAsState() val isSelectionMode by viewModel.isSelectionMode.collectAsState() val testResults by viewModel.linkTestResults.collectAsState() + val collections by viewModel.collections.collectAsState() + var showAddToCollectionDialog by remember { mutableStateOf(false) } Box( modifier = Modifier @@ -83,9 +88,25 @@ fun DeadLinksScreen( actions = { if (isSelectionMode) { IconButton( - onClick = { - viewModel.excludeSelectedFromHealthCheck() - } + onClick = { showAddToCollectionDialog = true } + ) { + Icon( + imageVector = Icons.Default.Folder, + contentDescription = "Ajouter à une collection", + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton( + onClick = { viewModel.verifySelectedLinks() } + ) { + Icon( + imageVector = Icons.Default.Link, + contentDescription = "Validation de fonctionnement des liens", + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton( + onClick = { viewModel.excludeSelectedFromHealthCheck() } ) { Icon( imageVector = Icons.Default.CheckCircle, @@ -103,6 +124,43 @@ fun DeadLinksScreen( }, containerColor = Color.Transparent ) { paddingValues -> + if (showAddToCollectionDialog) { + val regularCollections = remember(collections) { collections.filter { !it.isSmart } } + AlertDialog( + onDismissRequest = { showAddToCollectionDialog = false }, + title = { Text("Ajouter à une collection") }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + if (regularCollections.isEmpty()) { + Text("Aucune collection disponible.") + } else { + regularCollections.forEach { c -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + viewModel.addLinksToCollection(c.id, selectedLinkIds) + showAddToCollectionDialog = false + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(c.icon) + Spacer(modifier = Modifier.width(12.dp)) + Text(c.name) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { showAddToCollectionDialog = false }) { + Text("Fermer") + } + } + ) + } + Box( modifier = Modifier .padding(paddingValues) diff --git a/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksViewModel.kt b/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksViewModel.kt index 590f18e..9176665 100644 --- a/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksViewModel.kt @@ -7,24 +7,35 @@ import androidx.paging.cachedIn import com.shaarit.data.local.dao.LinkDao import com.shaarit.domain.model.ShaarliLink import com.shaarit.domain.repository.LinkRepository +import com.shaarit.data.local.dao.CollectionDao +import com.shaarit.data.local.entity.CollectionLinkCrossRef +import com.shaarit.data.sync.SyncManager +import com.shaarit.core.storage.TokenManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class DeadLinksViewModel @Inject constructor( private val linkRepository: LinkRepository, - private val linkDao: LinkDao + private val linkDao: LinkDao, + private val collectionDao: CollectionDao, + private val syncManager: SyncManager, + private val tokenManager: TokenManager ) : ViewModel() { val pagedDeadLinks: Flow> = linkRepository.getDeadLinksStream() .cachedIn(viewModelScope) + val collections = collectionDao.getAllCollections() + .stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5_000), emptyList()) + private val _selectedLinkIds = MutableStateFlow>(emptySet()) val selectedLinkIds: StateFlow> = _selectedLinkIds.asStateFlow() @@ -60,6 +71,36 @@ class DeadLinksViewModel @Inject constructor( } } + fun verifySelectedLinks() { + viewModelScope.launch { + val ids = _selectedLinkIds.value.toList() + if (ids.isNotEmpty()) { + val links = linkDao.getLinksByIds(ids) + for (link in links) { + verifyLink(link.id, link.url) + } + clearSelection() + } + } + } + + fun addLinksToCollection(collectionId: Long, linkIds: Set) { + if (linkIds.isEmpty()) return + viewModelScope.launch { + linkIds.forEach { linkId -> + try { + collectionDao.addLinkToCollection( + CollectionLinkCrossRef(collectionId = collectionId, linkId = linkId) + ) + } catch (_: Exception) { + } + } + tokenManager.setCollectionsConfigDirty(true) + syncManager.syncNow() + clearSelection() + } + } + fun deleteLink(id: Int) { viewModelScope.launch { linkRepository.deleteLink(id) diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt index c2540f9..9805dac 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt @@ -285,6 +285,7 @@ fun FeedScreen( onNavigateToReminders: () -> Unit = {}, onNavigateToTodo: () -> Unit = {}, onNavigateToTodoDetail: (Int) -> Unit = {}, + onNavigateToBrowser: (String) -> Unit = {}, onPlayAudio: ((com.shaarit.domain.model.ShaarliLink) -> Unit)? = null, initialTagFilter: String? = null, initialCollectionId: Long? = null, @@ -1709,6 +1710,9 @@ fun FeedScreen( reminderTargetLinkId = linkId reminderTargetLinkTitle = link.title showReminderSheet = true + }, + onOpenInternalClick = { url -> + onNavigateToBrowser(url) } ) } diff --git a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt index b34e1b6..123cd7b 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Alarm import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.TaskAlt +import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.ui.window.DialogProperties @@ -822,7 +823,8 @@ fun LinkDetailsView( onDismiss: () -> Unit, onLinkClick: (String) -> Unit, onReadClick: ((Int) -> Unit)? = null, - onReminderClick: ((Int) -> Unit)? = null + onReminderClick: ((Int) -> Unit)? = null, + onOpenInternalClick: ((String) -> Unit)? = null ) { Box( modifier = Modifier @@ -911,8 +913,9 @@ fun LinkDetailsView( Spacer(modifier = Modifier.height(12.dp)) // Reader Mode & Reminder actions - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (onReadClick != null && !link.url.startsWith("note://")) { OutlinedButton( @@ -935,6 +938,18 @@ fun LinkDetailsView( Text("Rappel", style = MaterialTheme.typography.labelMedium) } } + if (onOpenInternalClick != null && !link.url.startsWith("note://")) { + OutlinedButton( + onClick = { + onOpenInternalClick(link.url) + onDismiss() + } + ) { + Icon(Icons.Default.OpenInBrowser, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Ouvrir", style = MaterialTheme.typography.labelMedium) + } + } } Spacer(modifier = Modifier.height(16.dp)) diff --git a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt index 06219f7..a45828a 100644 --- a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt +++ b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt @@ -52,6 +52,9 @@ sealed class Screen(val route: String) { object TodoDetail : Screen("todoDetail/{linkId}") { fun createRoute(linkId: Int): String = "todoDetail/$linkId" } + object Browser : Screen("browser/{encodedUrl}") { + fun createRoute(encodedUrl: String): String = "browser/$encodedUrl" + } } @Composable @@ -167,6 +170,10 @@ fun AppNavGraph( onNavigateToReminders = { navController.navigate(Screen.Reminders.route) }, onNavigateToTodo = { navController.navigate(Screen.Todo.route) }, onNavigateToTodoDetail = { linkId -> navController.navigate(Screen.TodoDetail.createRoute(linkId)) }, + onNavigateToBrowser = { url -> + val encoded = URLEncoder.encode(url, "UTF-8") + navController.navigate(Screen.Browser.createRoute(encoded)) + }, onPlayAudio = onPlayAudio, initialTagFilter = tag, initialCollectionId = collectionId @@ -374,5 +381,20 @@ fun AppNavGraph( onNavigateBack = { navController.popBackStack() } ) } + + composable( + route = Screen.Browser.route, + arguments = listOf( + navArgument("encodedUrl") { + type = NavType.StringType + } + ) + ) { backStackEntry -> + val encodedUrl = backStackEntry.arguments?.getString("encodedUrl") ?: "" + com.shaarit.presentation.browser.InternalBrowserScreen( + encodedUrl = encodedUrl, + onNavigateBack = { navController.popBackStack() } + ) + } } } diff --git a/eatingwell.html b/eatingwell.html new file mode 100644 index 0000000..df11a22 Binary files /dev/null and b/eatingwell.html differ diff --git a/version.properties b/version.properties index 47b6346..a6da691 100644 --- a/version.properties +++ b/version.properties @@ -1,3 +1,3 @@ -#Wed Apr 22 22:23:35 2026 -VERSION_NAME=2.10.0 -VERSION_CODE=35 \ No newline at end of file +#Thu Apr 23 15:59:20 2026 +VERSION_NAME=2.12.0 +VERSION_CODE=38 \ No newline at end of file