feat: Implement link health monitoring system with periodic checks and dead link detection

- Add LinkHealthCheckWorker for automated bi-daily link validation (12-hour intervals)
- Extend LinkEntity with isDeadLink and lastHealthCheck fields for tracking link status
- Create LinkDao queries for dead link retrieval and health status updates
- Implement getDeadLinksStream() in LinkRepository for paginated dead link viewing
- Add GeminiRepository.generateTags() method with model fallback strategy and
This commit is contained in:
Bruno Charest 2026-01-31 13:19:53 -05:00
parent f88b7ffad3
commit 02c7300c3b
17 changed files with 661 additions and 35 deletions

View File

@ -3,7 +3,12 @@ package com.shaarit
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.shaarit.data.worker.LinkHealthCheckWorker
import dagger.hilt.android.HiltAndroidApp
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltAndroidApp
@ -13,4 +18,21 @@ class ShaarItApp : Application(), Configuration.Provider {
override val workManagerConfiguration: Configuration
get() = Configuration.Builder().setWorkerFactory(workerFactory).build()
override fun onCreate() {
super.onCreate()
setupHealthCheckWorker()
}
private fun setupHealthCheckWorker() {
val healthCheckRequest = PeriodicWorkRequestBuilder<LinkHealthCheckWorker>(
12, TimeUnit.HOURS // Run twice a day
).build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
LinkHealthCheckWorker.WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
healthCheckRequest
)
}
}

View File

@ -96,6 +96,25 @@ interface LinkDao {
""")
suspend fun countLinksBySearch(query: String): Int
@Query("""
SELECT * FROM links
WHERE is_dead_link = 1
ORDER BY last_health_check DESC
""")
fun getDeadLinks(): PagingSource<Int, LinkEntity>
@Query("""
SELECT * FROM links
WHERE url NOT LIKE 'note://%'
AND (last_health_check < :timestamp OR last_health_check IS NULL)
ORDER BY last_health_check ASC
LIMIT :limit
""")
suspend fun getLinksForHealthCheck(timestamp: Long, limit: Int): List<LinkEntity>
@Query("UPDATE links SET is_dead_link = :isDead, last_health_check = :timestamp WHERE id = :id")
suspend fun updateLinkHealthStatus(id: Int, isDead: Boolean, timestamp: Long)
// ====== Filtres temporels ======
@Query("""

View File

@ -69,7 +69,13 @@ data class LinkEntity(
val siteName: String? = null,
@ColumnInfo(name = "excerpt")
val excerpt: String? = null
val excerpt: String? = null,
@ColumnInfo(name = "is_dead_link")
val isDeadLink: Boolean = false,
@ColumnInfo(name = "last_health_check")
val lastHealthCheck: Long = 0
)
/**

View File

@ -20,6 +20,9 @@ class GeminiRepositoryImpl @Inject constructor(
private val tokenManager: TokenManager
) : GeminiRepository {
// Cache pour les tags
private val tagsCache = mutableMapOf<String, List<String>>()
// Cache en mémoire pour éviter de rappeler l'API pour la même URL durant la session
private val analysisCache = mutableMapOf<String, AiEnrichmentResult>()
@ -27,6 +30,86 @@ class GeminiRepositoryImpl @Inject constructor(
return !tokenManager.getGeminiApiKey().isNullOrBlank()
}
override suspend fun generateTags(title: String, description: String): Result<List<String>> = withContext(Dispatchers.IO) {
val cacheKey = "$title|$description"
if (tagsCache.containsKey(cacheKey)) {
return@withContext Result.success(tagsCache[cacheKey]!!)
}
try {
val apiKey = tokenManager.getGeminiApiKey()
if (apiKey.isNullOrBlank()) {
return@withContext Result.failure(Exception("Clé API Gemini non configurée."))
}
val modelsToTry = listOf(
"gemini-2.5-flash-lite",
"gemini-2.5-flash",
"gemini-3-flash",
"gemini-2.0-flash-lite",
"gemini-1.5-flash"
)
var lastException: Exception? = null
for (modelName in modelsToTry) {
try {
val tags = generateTagsWithModel(apiKey, modelName, title, description)
tagsCache[cacheKey] = tags
return@withContext Result.success(tags)
} catch (e: Exception) {
lastException = e
val msg = (e.message ?: "").lowercase()
val isRetryable = msg.contains("404") || msg.contains("not found") ||
msg.contains("429") || msg.contains("quota") ||
msg.contains("exhausted")
if (!isRetryable) break
}
}
Result.failure(lastException ?: Exception("Impossible de générer des tags"))
} catch (e: Exception) {
Result.failure(e)
}
}
private suspend fun generateTagsWithModel(apiKey: String, modelName: String, title: String, description: String): List<String> {
val generativeModel = GenerativeModel(
modelName = modelName,
apiKey = apiKey,
generationConfig = generationConfig {
temperature = 0.5f // Plus bas pour des tags précis
maxOutputTokens = 512
}
)
val prompt = """
Tâche: Générer des tags pertinents pour ce contenu.
Titre: $title
Description: $description
Consignes:
1. Suggère 5 à 10 tags pertinents.
2. Tags en lowercase, mots simples ou composés avec tirets.
3. Réponds UNIQUEMENT avec un tableau JSON de chaînes.
Exemple: ["android", "kotlin", "dev-tools"]
""".trimIndent()
val response = generativeModel.generateContent(content { text(prompt) })
val responseText = response.text ?: throw Exception("Réponse vide")
return parseTagsResponse(responseText)
}
private fun parseTagsResponse(responseText: String): List<String> {
val cleaned = responseText.replace("```json", "").replace("```", "").trim()
val jsonArray = org.json.JSONArray(cleaned)
val tags = mutableListOf<String>()
for (i in 0 until jsonArray.length()) {
tags.add(jsonArray.getString(i))
}
return tags
}
override suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult> = withContext(Dispatchers.IO) {
// Vérifier le cache d'abord
if (analysisCache.containsKey(url)) {
@ -144,28 +227,29 @@ class GeminiRepositoryImpl @Inject constructor(
private fun buildPrompt(url: String): String {
return """
Analyse cette URL: $url
Rôle: Tu es un assistant expert en classification de contenu web pour l'application Shaarli.
Tu dois analyser le contenu de cette page web et générer les informations suivantes.
Tâche: Analyser les métadonnées de l'URL fournie pour extraire un titre, une description et des tags.
Consignes strictes:
1. Génère un titre pertinent et concis (max 100 caractères)
2. Génère une description résumée du contenu (max 200 mots)
3. Génère une liste de 5 à 15 tags pertinents (en lowercase, sans espaces, utilisez des tirets si nécessaire)
4. Classe le contenu dans UN des types suivants: Article, Video, Tutorial, GitRepository, Other
URL à analyser: $url
Réponds UNIQUEMENT en format JSON strict, sans markdown, sans commentaires, exactement comme ceci:
Règles CRITIQUES de sécurité anti-hallucination :
1. Si tu ne peux pas accéder au contenu réel de la page ou si l'URL est opaque (ex: raccourci, ID vidéo), NE DEVINE PAS. Base-toi uniquement sur les informations explicites dans l'URL.
2. Si c'est une vidéo YouTube, essaie d'extraire le contexte de l'ID si tu le connais, sinon indique "Vérifier le titre" dans le titre.
3. Ne jamais inventer de détails (comme le prix d'une maison) si tu n'as pas la preuve visuelle ou textuelle.
Format de sortie (JSON Strict uniquement) :
{
"title": "Le titre ici",
"description": "La description ici",
"title": "Titre extrait ou 'Titre non trouvé'",
"description": "Résumé factuel (max 200 mots).",
"tags": ["tag1", "tag2", "tag3"],
"contentType": "Article"
"contentType": "Un parmi: Article, Video, Tutorial, GitRepository, Other"
}
Important:
- Les tags doivent être en lowercase
- Le contentType doit être exactement l'une de ces valeurs: Article, Video, Tutorial, GitRepository, Other
- Ne pas inclure de backticks ou de formatage markdown
Consignes :
- Tags en lowercase, sans accents si possible.
- ContentType strict.
- JSON valide sans Markdown (pas de ```json).
""".trimIndent()
}

View File

@ -505,6 +505,15 @@ constructor(
return linkDao.getLinksWithFilters(SimpleSQLiteQuery(sql, args.toTypedArray()))
}
override fun getDeadLinksStream(): Flow<PagingData<ShaarliLink>> {
return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
pagingSourceFactory = { linkDao.getDeadLinks() }
).flow.map { pagingData ->
pagingData.map { it.toDomainModel() }
}
}
private fun parseExistingLink(errorBody: String?): LinkDto? {
if (errorBody.isNullOrBlank()) return null
return try {
@ -528,7 +537,8 @@ constructor(
thumbnailUrl = thumbnailUrl,
readingTime = readingTimeMinutes,
contentType = contentType.name,
siteName = siteName
siteName = siteName,
isDeadLink = isDeadLink
)
}

View File

@ -0,0 +1,81 @@
package com.shaarit.data.worker
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.shaarit.data.local.dao.LinkDao
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
@HiltWorker
class LinkHealthCheckWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val linkDao: LinkDao
) : CoroutineWorker(appContext, workerParams) {
companion object {
const val WORK_NAME = "link_health_check_work"
private const val BATCH_SIZE = 20
private const val CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000L // 24h
private const val TIMEOUT_MS = 10000
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
try {
// Récupérer les liens qui n'ont pas été vérifiés récemment
// On vérifie ceux qui ont été checkés il y a plus de 24h (ou jamais)
val threshold = System.currentTimeMillis() - CHECK_INTERVAL_MS
val linksToCheck = linkDao.getLinksForHealthCheck(threshold, BATCH_SIZE)
if (linksToCheck.isEmpty()) {
return@withContext Result.success()
}
var checkCount = 0
linksToCheck.forEach { link ->
// Double vérification pour éviter les notes (déjà filtré par DAO normalement mais sécurité)
if (link.url.startsWith("note://")) {
return@forEach
}
val isDead = !isUrlAccessible(link.url)
linkDao.updateLinkHealthStatus(link.id, isDead, System.currentTimeMillis())
checkCount++
}
// S'il reste des liens à vérifier, on renvoie retry ou success pour que le prochain run (périodique) s'en charge
// Comme c'est un Worker périodique, Success suffit, il sera relancé plus tard par le scheduler
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
private fun isUrlAccessible(url: String): Boolean {
return try {
val connection = URL(url).openConnection() as HttpURLConnection
connection.requestMethod = "HEAD"
connection.connectTimeout = TIMEOUT_MS
connection.readTimeout = TIMEOUT_MS
connection.instanceFollowRedirects = true
// On accepte les codes 2xx et 3xx comme "vivants"
// Certains sites bloquent HEAD, on pourrait fallback sur GET mais c'est lourd.
// Pour l'instant on reste simple.
// Parfois 405 Method Not Allowed est retourné pour HEAD, ce qui veut dire que le serveur existe.
val responseCode = connection.responseCode
connection.disconnect()
responseCode in 200..399 || responseCode == 405
} catch (e: Exception) {
false
}
}
}

View File

@ -14,5 +14,6 @@ data class ShaarliLink(
val thumbnailUrl: String? = null,
val readingTime: Int? = null,
val contentType: String? = null,
val siteName: String? = null
val siteName: String? = null,
val isDeadLink: Boolean = false
)

View File

@ -4,5 +4,6 @@ import com.shaarit.domain.model.AiEnrichmentResult
interface GeminiRepository {
suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult>
suspend fun generateTags(title: String, description: String): Result<List<String>>
fun isApiKeyConfigured(): Boolean
}

View File

@ -59,4 +59,6 @@ interface LinkRepository {
suspend fun getAllLinks(): Result<List<ShaarliLink>>
suspend fun updateLinkClassification(id: Int, contentType: String?, siteName: String?, tagsToAdd: List<String>? = null): Result<Unit>
fun getDeadLinksStream(): Flow<PagingData<ShaarliLink>>
}

View File

@ -0,0 +1,19 @@
package com.shaarit.domain.usecase
import com.shaarit.domain.repository.GeminiRepository
import javax.inject.Inject
class GenerateTagsWithAiUseCase @Inject constructor(
private val geminiRepository: GeminiRepository
) {
suspend operator fun invoke(title: String, description: String): Result<List<String>> {
if (title.isBlank() && description.isBlank()) {
return Result.failure(Exception("Titre et description vides"))
}
return geminiRepository.generateTags(title, description)
}
fun isApiKeyConfigured(): Boolean {
return geminiRepository.isApiKeyConfigured()
}
}

View File

@ -0,0 +1,144 @@
package com.shaarit.presentation.deadlinks
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.LoadState
import androidx.paging.compose.collectAsLazyPagingItems
import com.shaarit.presentation.feed.ListViewItem
import com.shaarit.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeadLinksScreen(
onNavigateBack: () -> Unit,
onNavigateToEdit: (Int) -> Unit,
viewModel: DeadLinksViewModel = hiltViewModel()
) {
val pagingItems = viewModel.pagedDeadLinks.collectAsLazyPagingItems()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient(
colors = listOf(DeepNavy, DarkNavy)
)
)
) {
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
"Liens inaccessibles",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Retour",
tint = TextPrimary
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = DeepNavy.copy(alpha = 0.9f),
titleContentColor = TextPrimary
)
)
},
containerColor = Color.Transparent
) { paddingValues ->
Box(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
if (pagingItems.loadState.refresh is LoadState.Loading && pagingItems.itemCount == 0) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = CyanPrimary)
}
} else if (pagingItems.itemCount == 0) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.BrokenImage,
contentDescription = null,
tint = TextMuted,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"Aucun lien mort détecté",
style = MaterialTheme.typography.titleMedium,
color = TextSecondary
)
Text(
"Tout semble fonctionner !",
style = MaterialTheme.typography.bodyMedium,
color = TextMuted
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(count = pagingItems.itemCount) { index ->
val link = pagingItems[index]
if (link != null) {
// On utilise ListViewItem en lui passant un indicateur visuel
// Note: Pour l'instant ListViewItem ne gère pas isDeadLink visuellement,
// on va devoir le modifier ou wrapper l'item.
// Le viewmodel gère la suppression.
ListViewItem(
link = link,
onItemClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
context.startActivity(intent)
},
onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
},
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) },
// Pas de tags clickables ici pour simplifier
onTagClick = { },
onViewClick = { }
)
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,28 @@
package com.shaarit.presentation.deadlinks
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.repository.LinkRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class DeadLinksViewModel @Inject constructor(
private val linkRepository: LinkRepository
) : ViewModel() {
val pagedDeadLinks: Flow<PagingData<ShaarliLink>> =
linkRepository.getDeadLinksStream()
.cachedIn(viewModelScope)
fun deleteLink(id: Int) {
viewModelScope.launch {
linkRepository.deleteLink(id)
}
}
}

View File

@ -54,6 +54,8 @@ fun EditLinkScreen(
val isPrivate by viewModel.isPrivate.collectAsState()
val tagSuggestions by viewModel.tagSuggestions.collectAsState()
val contentType by viewModel.contentType.collectAsState()
val isExtractingMetadata by viewModel.isExtractingMetadata.collectAsState()
val aiTagsState by viewModel.aiTagsState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
@ -94,6 +96,13 @@ fun EditLinkScreen(
}
}
LaunchedEffect(aiTagsState) {
if (aiTagsState is AiEnrichmentState.Success) {
snackbarHostState.showSnackbar("✨ Tags IA générés !")
viewModel.resetAiTagsState()
}
}
Box(
modifier = Modifier
.fillMaxSize()
@ -214,6 +223,32 @@ fun EditLinkScreen(
textStyle = MaterialTheme.typography.bodyMedium
)
// Classic Fetch Button
IconButton(
onClick = { viewModel.fetchMetadataClassic() },
enabled = url.isNotBlank() && !isExtractingMetadata,
modifier = Modifier
.size(48.dp) // Même taille que AiMagicButton
.background(
color = SurfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(12.dp)
)
) {
if (isExtractingMetadata) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = TextSecondary,
strokeWidth = 2.dp
)
} else {
Icon(
Icons.Default.Refresh,
contentDescription = "Récupérer infos classique",
tint = TextSecondary
)
}
}
// AI Magic Button
AiMagicButton(
onClick = { viewModel.analyzeUrlWithAi() },
@ -448,6 +483,35 @@ fun EditLinkScreen(
tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted
)
}
// Bouton Tags IA
IconButton(
onClick = { viewModel.generateTagsWithAi() },
enabled = aiTagsState !is AiEnrichmentState.Loading,
modifier = Modifier
.size(40.dp)
.background(
color = if (aiTagsState is AiEnrichmentState.Loading)
SurfaceVariant.copy(alpha = 0.5f)
else
CyanPrimary.copy(alpha = 0.1f),
shape = RoundedCornerShape(12.dp)
)
) {
if (aiTagsState is AiEnrichmentState.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = CyanPrimary,
strokeWidth = 2.dp
)
} else {
Icon(
Icons.Outlined.AutoAwesome,
contentDescription = "Générer tags IA",
tint = CyanPrimary
)
}
}
}
// Tag suggestions

View File

@ -8,6 +8,8 @@ import com.shaarit.domain.model.ShaarliTag
import com.shaarit.domain.repository.LinkRepository
import com.shaarit.domain.usecase.AnalyzeUrlWithAiUseCase
import com.shaarit.presentation.add.ContentType
import com.shaarit.data.metadata.LinkMetadataExtractor
import com.shaarit.domain.usecase.GenerateTagsWithAiUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow
@ -22,6 +24,8 @@ class EditLinkViewModel
constructor(
private val linkRepository: LinkRepository,
private val analyzeUrlWithAiUseCase: AnalyzeUrlWithAiUseCase,
private val generateTagsWithAiUseCase: GenerateTagsWithAiUseCase,
private val metadataExtractor: LinkMetadataExtractor,
savedStateHandle: SavedStateHandle
) : ViewModel() {
@ -57,6 +61,12 @@ constructor(
private val _aiEnrichmentState = MutableStateFlow<AiEnrichmentState>(AiEnrichmentState.Idle)
val aiEnrichmentState = _aiEnrichmentState.asStateFlow()
private val _aiTagsState = MutableStateFlow<AiEnrichmentState>(AiEnrichmentState.Idle)
val aiTagsState = _aiTagsState.asStateFlow()
private val _isExtractingMetadata = MutableStateFlow(false)
val isExtractingMetadata = _isExtractingMetadata.asStateFlow()
private val _aiErrorMessage = MutableSharedFlow<String>()
val aiErrorMessage = _aiErrorMessage.asSharedFlow()
@ -156,6 +166,82 @@ constructor(
addTag(_newTagInput.value)
}
fun fetchMetadataClassic() {
val currentUrl = url.value
if (currentUrl.isBlank()) {
viewModelScope.launch {
_aiErrorMessage.emit("Veuillez d'abord entrer une URL")
}
return
}
viewModelScope.launch {
_isExtractingMetadata.value = true
try {
val metadata = metadataExtractor.extract(currentUrl)
// Appliquer les métadonnées (Emoji 🔖 uniquement sur la description)
if (!metadata.title.isNullOrBlank()) {
title.value = metadata.title
}
if (!metadata.description.isNullOrBlank()) {
description.value = "🔖 ${metadata.description}"
}
// Pas de tags auto en mode classique, sauf si on veut extraire ceux du site (pas demandé spécifiquement mais logique)
// Le user a demandé "fetch information de l'URL", donc titre et description.
_aiErrorMessage.emit("Métadonnées récupérées (Mode Classique)")
} catch (e: Exception) {
_aiErrorMessage.emit("Erreur récupération classique: ${e.message}")
} finally {
_isExtractingMetadata.value = false
}
}
}
fun generateTagsWithAi() {
val currentTitle = title.value.replace("", "").replace("🔖 ", "")
val currentDescription = description.value.replace("", "").replace("🔖 ", "")
if (currentTitle.isBlank() && currentDescription.isBlank()) {
viewModelScope.launch {
_aiErrorMessage.emit("Titre ou description requis pour générer des tags")
}
return
}
if (!generateTagsWithAiUseCase.isApiKeyConfigured()) {
viewModelScope.launch {
_aiErrorMessage.emit("Clé API Gemini non configurée.")
}
return
}
viewModelScope.launch {
_aiTagsState.value = AiEnrichmentState.Loading
generateTagsWithAiUseCase(currentTitle, currentDescription)
.onSuccess { tags ->
val currentTags = _selectedTags.value.toMutableSet()
tags.forEach { tag ->
val cleanTag = tag.trim().lowercase()
if (cleanTag.isNotBlank()) {
currentTags.add(cleanTag)
}
}
_selectedTags.value = currentTags.toList()
_aiTagsState.value = AiEnrichmentState.Success
_aiErrorMessage.emit("Tags générés par IA !")
}
.onFailure { error ->
_aiTagsState.value = AiEnrichmentState.Idle
_aiErrorMessage.emit("Erreur tags IA: ${error.message}")
}
}
}
fun analyzeUrlWithAi() {
val currentUrl = url.value
if (currentUrl.isBlank()) {
@ -188,8 +274,9 @@ constructor(
}
private fun applyAiEnrichment(result: AiEnrichmentResult) {
// Ajout de l'émoji ✨ au début (Uniquement description)
title.value = result.title
description.value = result.description
description.value = "${result.description}"
// Add AI-generated tags to existing tags (without duplicates)
val currentTags = _selectedTags.value.toMutableSet()
@ -202,6 +289,10 @@ constructor(
_selectedTags.value = currentTags.toList()
}
fun resetAiTagsState() {
_aiTagsState.value = AiEnrichmentState.Idle
}
fun resetAiEnrichmentState() {
_aiEnrichmentState.value = AiEnrichmentState.Idle
}

View File

@ -272,6 +272,7 @@ fun FeedScreen(
onNavigateToSettings: () -> Unit = {},
onNavigateToRandom: () -> Unit = {},
onNavigateToHelp: () -> Unit = {},
onNavigateToDeadLinks: () -> Unit = {},
initialTagFilter: String? = null,
initialCollectionId: Long? = null,
viewModel: FeedViewModel = hiltViewModel()
@ -433,6 +434,15 @@ fun FeedScreen(
onNavigateToHelp()
}
)
DrawerNavigationItem(
icon = Icons.Default.BrokenImage,
label = "Liens inaccessibles",
onClick = {
scope.launch { drawerState.close() }
onNavigateToDeadLinks()
}
)
}
}

View File

@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.window.DialogProperties
@ -88,13 +89,24 @@ fun ListViewItem(
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = link.url,
style = MaterialTheme.typography.bodySmall,
color = TealSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (link.isDeadLink) {
Icon(
imageVector = Icons.Default.BrokenImage,
contentDescription = "Lien mort",
tint = ErrorRed,
modifier = Modifier.size(16.dp).padding(end = 4.dp)
)
}
Text(
text = link.url,
style = MaterialTheme.typography.bodySmall,
color = if (link.isDeadLink) ErrorRed else TealSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Row {
@ -246,11 +258,20 @@ fun GridViewItem(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
if (link.isDeadLink) {
Icon(
imageVector = Icons.Default.BrokenImage,
contentDescription = "Lien mort",
tint = ErrorRed,
modifier = Modifier.size(16.dp).padding(end = 4.dp).align(Alignment.CenterVertically)
)
}
Text(
text = link.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = CyanPrimary,
color = if (link.isDeadLink) ErrorRed else CyanPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
@ -467,14 +488,24 @@ fun CompactViewItem(
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = link.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = CyanPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (link.isDeadLink) {
Icon(
imageVector = Icons.Default.BrokenImage,
contentDescription = "Lien mort",
tint = ErrorRed,
modifier = Modifier.size(14.dp).padding(end = 4.dp)
)
}
Text(
text = link.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (link.isDeadLink) ErrorRed else CyanPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),

View File

@ -42,6 +42,7 @@ sealed class Screen(val route: String) {
object Dashboard : Screen("dashboard")
object Settings : Screen("settings")
object Help : Screen("help")
object DeadLinks : Screen("dead_links")
}
@Composable
@ -125,6 +126,7 @@ fun AppNavGraph(
onNavigateToSettings = { navController.navigate(Screen.Settings.route) },
onNavigateToRandom = { },
onNavigateToHelp = { navController.navigate(Screen.Help.route) },
onNavigateToDeadLinks = { navController.navigate(Screen.DeadLinks.route) },
initialTagFilter = tag,
initialCollectionId = collectionId
)
@ -256,5 +258,16 @@ fun AppNavGraph(
onNavigateBack = { navController.popBackStack() }
)
}
composable(
route = Screen.DeadLinks.route
) {
com.shaarit.presentation.deadlinks.DeadLinksScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { linkId ->
navController.navigate(Screen.Edit.createRoute(linkId))
}
)
}
}
}