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