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:
parent
f88b7ffad3
commit
02c7300c3b
@ -3,7 +3,12 @@ package com.shaarit
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.hilt.work.HiltWorkerFactory
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
import androidx.work.Configuration
|
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 dagger.hilt.android.HiltAndroidApp
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
@ -13,4 +18,21 @@ class ShaarItApp : Application(), Configuration.Provider {
|
|||||||
|
|
||||||
override val workManagerConfiguration: Configuration
|
override val workManagerConfiguration: Configuration
|
||||||
get() = Configuration.Builder().setWorkerFactory(workerFactory).build()
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -96,6 +96,25 @@ interface LinkDao {
|
|||||||
""")
|
""")
|
||||||
suspend fun countLinksBySearch(query: String): Int
|
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 ======
|
// ====== Filtres temporels ======
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
|
|||||||
@ -69,7 +69,13 @@ data class LinkEntity(
|
|||||||
val siteName: String? = null,
|
val siteName: String? = null,
|
||||||
|
|
||||||
@ColumnInfo(name = "excerpt")
|
@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
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -20,6 +20,9 @@ class GeminiRepositoryImpl @Inject constructor(
|
|||||||
private val tokenManager: TokenManager
|
private val tokenManager: TokenManager
|
||||||
) : GeminiRepository {
|
) : 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
|
// Cache en mémoire pour éviter de rappeler l'API pour la même URL durant la session
|
||||||
private val analysisCache = mutableMapOf<String, AiEnrichmentResult>()
|
private val analysisCache = mutableMapOf<String, AiEnrichmentResult>()
|
||||||
|
|
||||||
@ -27,6 +30,86 @@ class GeminiRepositoryImpl @Inject constructor(
|
|||||||
return !tokenManager.getGeminiApiKey().isNullOrBlank()
|
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) {
|
override suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult> = withContext(Dispatchers.IO) {
|
||||||
// Vérifier le cache d'abord
|
// Vérifier le cache d'abord
|
||||||
if (analysisCache.containsKey(url)) {
|
if (analysisCache.containsKey(url)) {
|
||||||
@ -144,28 +227,29 @@ class GeminiRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
private fun buildPrompt(url: String): String {
|
private fun buildPrompt(url: String): String {
|
||||||
return """
|
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:
|
URL à analyser: $url
|
||||||
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
|
|
||||||
|
|
||||||
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",
|
"title": "Titre extrait ou 'Titre non trouvé'",
|
||||||
"description": "La description ici",
|
"description": "Résumé factuel (max 200 mots).",
|
||||||
"tags": ["tag1", "tag2", "tag3"],
|
"tags": ["tag1", "tag2", "tag3"],
|
||||||
"contentType": "Article"
|
"contentType": "Un parmi: Article, Video, Tutorial, GitRepository, Other"
|
||||||
}
|
}
|
||||||
|
|
||||||
Important:
|
Consignes :
|
||||||
- Les tags doivent être en lowercase
|
- Tags en lowercase, sans accents si possible.
|
||||||
- Le contentType doit être exactement l'une de ces valeurs: Article, Video, Tutorial, GitRepository, Other
|
- ContentType strict.
|
||||||
- Ne pas inclure de backticks ou de formatage markdown
|
- JSON valide sans Markdown (pas de ```json).
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -505,6 +505,15 @@ constructor(
|
|||||||
return linkDao.getLinksWithFilters(SimpleSQLiteQuery(sql, args.toTypedArray()))
|
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? {
|
private fun parseExistingLink(errorBody: String?): LinkDto? {
|
||||||
if (errorBody.isNullOrBlank()) return null
|
if (errorBody.isNullOrBlank()) return null
|
||||||
return try {
|
return try {
|
||||||
@ -528,7 +537,8 @@ constructor(
|
|||||||
thumbnailUrl = thumbnailUrl,
|
thumbnailUrl = thumbnailUrl,
|
||||||
readingTime = readingTimeMinutes,
|
readingTime = readingTimeMinutes,
|
||||||
contentType = contentType.name,
|
contentType = contentType.name,
|
||||||
siteName = siteName
|
siteName = siteName,
|
||||||
|
isDeadLink = isDeadLink
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,5 +14,6 @@ data class ShaarliLink(
|
|||||||
val thumbnailUrl: String? = null,
|
val thumbnailUrl: String? = null,
|
||||||
val readingTime: Int? = null,
|
val readingTime: Int? = null,
|
||||||
val contentType: String? = null,
|
val contentType: String? = null,
|
||||||
val siteName: String? = null
|
val siteName: String? = null,
|
||||||
|
val isDeadLink: Boolean = false
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,5 +4,6 @@ import com.shaarit.domain.model.AiEnrichmentResult
|
|||||||
|
|
||||||
interface GeminiRepository {
|
interface GeminiRepository {
|
||||||
suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult>
|
suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult>
|
||||||
|
suspend fun generateTags(title: String, description: String): Result<List<String>>
|
||||||
fun isApiKeyConfigured(): Boolean
|
fun isApiKeyConfigured(): Boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,4 +59,6 @@ interface LinkRepository {
|
|||||||
suspend fun getAllLinks(): Result<List<ShaarliLink>>
|
suspend fun getAllLinks(): Result<List<ShaarliLink>>
|
||||||
|
|
||||||
suspend fun updateLinkClassification(id: Int, contentType: String?, siteName: String?, tagsToAdd: List<String>? = null): Result<Unit>
|
suspend fun updateLinkClassification(id: Int, contentType: String?, siteName: String?, tagsToAdd: List<String>? = null): Result<Unit>
|
||||||
|
|
||||||
|
fun getDeadLinksStream(): Flow<PagingData<ShaarliLink>>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 = { }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -54,6 +54,8 @@ fun EditLinkScreen(
|
|||||||
val isPrivate by viewModel.isPrivate.collectAsState()
|
val isPrivate by viewModel.isPrivate.collectAsState()
|
||||||
val tagSuggestions by viewModel.tagSuggestions.collectAsState()
|
val tagSuggestions by viewModel.tagSuggestions.collectAsState()
|
||||||
val contentType by viewModel.contentType.collectAsState()
|
val contentType by viewModel.contentType.collectAsState()
|
||||||
|
val isExtractingMetadata by viewModel.isExtractingMetadata.collectAsState()
|
||||||
|
val aiTagsState by viewModel.aiTagsState.collectAsState()
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val focusManager = LocalFocusManager.current
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@ -214,6 +223,32 @@ fun EditLinkScreen(
|
|||||||
textStyle = MaterialTheme.typography.bodyMedium
|
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
|
// AI Magic Button
|
||||||
AiMagicButton(
|
AiMagicButton(
|
||||||
onClick = { viewModel.analyzeUrlWithAi() },
|
onClick = { viewModel.analyzeUrlWithAi() },
|
||||||
@ -448,6 +483,35 @@ fun EditLinkScreen(
|
|||||||
tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted
|
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
|
// Tag suggestions
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import com.shaarit.domain.model.ShaarliTag
|
|||||||
import com.shaarit.domain.repository.LinkRepository
|
import com.shaarit.domain.repository.LinkRepository
|
||||||
import com.shaarit.domain.usecase.AnalyzeUrlWithAiUseCase
|
import com.shaarit.domain.usecase.AnalyzeUrlWithAiUseCase
|
||||||
import com.shaarit.presentation.add.ContentType
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
@ -22,6 +24,8 @@ class EditLinkViewModel
|
|||||||
constructor(
|
constructor(
|
||||||
private val linkRepository: LinkRepository,
|
private val linkRepository: LinkRepository,
|
||||||
private val analyzeUrlWithAiUseCase: AnalyzeUrlWithAiUseCase,
|
private val analyzeUrlWithAiUseCase: AnalyzeUrlWithAiUseCase,
|
||||||
|
private val generateTagsWithAiUseCase: GenerateTagsWithAiUseCase,
|
||||||
|
private val metadataExtractor: LinkMetadataExtractor,
|
||||||
savedStateHandle: SavedStateHandle
|
savedStateHandle: SavedStateHandle
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@ -57,6 +61,12 @@ constructor(
|
|||||||
private val _aiEnrichmentState = MutableStateFlow<AiEnrichmentState>(AiEnrichmentState.Idle)
|
private val _aiEnrichmentState = MutableStateFlow<AiEnrichmentState>(AiEnrichmentState.Idle)
|
||||||
val aiEnrichmentState = _aiEnrichmentState.asStateFlow()
|
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>()
|
private val _aiErrorMessage = MutableSharedFlow<String>()
|
||||||
val aiErrorMessage = _aiErrorMessage.asSharedFlow()
|
val aiErrorMessage = _aiErrorMessage.asSharedFlow()
|
||||||
|
|
||||||
@ -156,6 +166,82 @@ constructor(
|
|||||||
addTag(_newTagInput.value)
|
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() {
|
fun analyzeUrlWithAi() {
|
||||||
val currentUrl = url.value
|
val currentUrl = url.value
|
||||||
if (currentUrl.isBlank()) {
|
if (currentUrl.isBlank()) {
|
||||||
@ -188,8 +274,9 @@ constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun applyAiEnrichment(result: AiEnrichmentResult) {
|
private fun applyAiEnrichment(result: AiEnrichmentResult) {
|
||||||
|
// Ajout de l'émoji ✨ au début (Uniquement description)
|
||||||
title.value = result.title
|
title.value = result.title
|
||||||
description.value = result.description
|
description.value = "✨ ${result.description}"
|
||||||
|
|
||||||
// Add AI-generated tags to existing tags (without duplicates)
|
// Add AI-generated tags to existing tags (without duplicates)
|
||||||
val currentTags = _selectedTags.value.toMutableSet()
|
val currentTags = _selectedTags.value.toMutableSet()
|
||||||
@ -202,6 +289,10 @@ constructor(
|
|||||||
_selectedTags.value = currentTags.toList()
|
_selectedTags.value = currentTags.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun resetAiTagsState() {
|
||||||
|
_aiTagsState.value = AiEnrichmentState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
fun resetAiEnrichmentState() {
|
fun resetAiEnrichmentState() {
|
||||||
_aiEnrichmentState.value = AiEnrichmentState.Idle
|
_aiEnrichmentState.value = AiEnrichmentState.Idle
|
||||||
}
|
}
|
||||||
|
|||||||
@ -272,6 +272,7 @@ fun FeedScreen(
|
|||||||
onNavigateToSettings: () -> Unit = {},
|
onNavigateToSettings: () -> Unit = {},
|
||||||
onNavigateToRandom: () -> Unit = {},
|
onNavigateToRandom: () -> Unit = {},
|
||||||
onNavigateToHelp: () -> Unit = {},
|
onNavigateToHelp: () -> Unit = {},
|
||||||
|
onNavigateToDeadLinks: () -> Unit = {},
|
||||||
initialTagFilter: String? = null,
|
initialTagFilter: String? = null,
|
||||||
initialCollectionId: Long? = null,
|
initialCollectionId: Long? = null,
|
||||||
viewModel: FeedViewModel = hiltViewModel()
|
viewModel: FeedViewModel = hiltViewModel()
|
||||||
@ -433,6 +434,15 @@ fun FeedScreen(
|
|||||||
onNavigateToHelp()
|
onNavigateToHelp()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DrawerNavigationItem(
|
||||||
|
icon = Icons.Default.BrokenImage,
|
||||||
|
label = "Liens inaccessibles",
|
||||||
|
onClick = {
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
onNavigateToDeadLinks()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.Lock
|
|||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.PushPin
|
import androidx.compose.material.icons.filled.PushPin
|
||||||
|
import androidx.compose.material.icons.filled.BrokenImage
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
@ -88,14 +89,25 @@ fun ListViewItem(
|
|||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
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(
|
||||||
text = link.url,
|
text = link.url,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = TealSecondary,
|
color = if (link.isDeadLink) ErrorRed else TealSecondary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
if (selectionMode) {
|
if (selectionMode) {
|
||||||
@ -246,11 +258,20 @@ fun GridViewItem(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier.fillMaxWidth()
|
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(
|
||||||
text = link.title,
|
text = link.title,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = CyanPrimary,
|
color = if (link.isDeadLink) ErrorRed else CyanPrimary,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
@ -467,14 +488,24 @@ fun CompactViewItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
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(
|
||||||
text = link.title,
|
text = link.title,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = CyanPrimary,
|
color = if (link.isDeadLink) ErrorRed else CyanPrimary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
|||||||
@ -42,6 +42,7 @@ sealed class Screen(val route: String) {
|
|||||||
object Dashboard : Screen("dashboard")
|
object Dashboard : Screen("dashboard")
|
||||||
object Settings : Screen("settings")
|
object Settings : Screen("settings")
|
||||||
object Help : Screen("help")
|
object Help : Screen("help")
|
||||||
|
object DeadLinks : Screen("dead_links")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -125,6 +126,7 @@ fun AppNavGraph(
|
|||||||
onNavigateToSettings = { navController.navigate(Screen.Settings.route) },
|
onNavigateToSettings = { navController.navigate(Screen.Settings.route) },
|
||||||
onNavigateToRandom = { },
|
onNavigateToRandom = { },
|
||||||
onNavigateToHelp = { navController.navigate(Screen.Help.route) },
|
onNavigateToHelp = { navController.navigate(Screen.Help.route) },
|
||||||
|
onNavigateToDeadLinks = { navController.navigate(Screen.DeadLinks.route) },
|
||||||
initialTagFilter = tag,
|
initialTagFilter = tag,
|
||||||
initialCollectionId = collectionId
|
initialCollectionId = collectionId
|
||||||
)
|
)
|
||||||
@ -256,5 +258,16 @@ fun AppNavGraph(
|
|||||||
onNavigateBack = { navController.popBackStack() }
|
onNavigateBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Screen.DeadLinks.route
|
||||||
|
) {
|
||||||
|
com.shaarit.presentation.deadlinks.DeadLinksScreen(
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
onNavigateToEdit = { linkId ->
|
||||||
|
navController.navigate(Screen.Edit.createRoute(linkId))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user