chore: Remove outdated documentation files (analysis and build instructions)

This commit is contained in:
Bruno Charest 2026-02-09 09:48:25 -05:00
parent 02c7300c3b
commit 80ab3009aa
25 changed files with 1189 additions and 241 deletions

View File

@ -3,7 +3,9 @@ package com.shaarit
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.shaarit.data.worker.LinkHealthCheckWorker
@ -25,9 +27,17 @@ class ShaarItApp : Application(), Configuration.Provider {
}
private fun setupHealthCheckWorker() {
// Contraintes: Wi-Fi uniquement + appareil en charge
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // Wi-Fi uniquement
.setRequiresCharging(true) // En charge pour ne pas nuire à l'utilisateur
.build()
val healthCheckRequest = PeriodicWorkRequestBuilder<LinkHealthCheckWorker>(
12, TimeUnit.HOURS // Run twice a day
).build()
12, TimeUnit.HOURS // Toutes les 12h
)
.setConstraints(constraints)
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
LinkHealthCheckWorker.WORK_NAME,

View File

@ -1,10 +1,14 @@
package com.shaarit.core.di
import android.content.Context
import androidx.work.WorkManager
import com.shaarit.core.storage.TokenManager
import com.shaarit.core.storage.TokenManagerImpl
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@ -13,4 +17,12 @@ import javax.inject.Singleton
abstract class AppModule {
@Binds @Singleton abstract fun bindTokenManager(impl: TokenManagerImpl): TokenManager
companion object {
@Provides
@Singleton
fun provideWorkManager(@ApplicationContext context: Context): WorkManager {
return WorkManager.getInstance(context)
}
}
}

View File

@ -11,6 +11,7 @@ import androidx.room.Transaction
import androidx.room.Update
import androidx.sqlite.db.SupportSQLiteQuery
import com.shaarit.data.local.entity.ContentType
import com.shaarit.data.local.entity.LinkCheckStatus
import com.shaarit.data.local.entity.LinkEntity
import com.shaarit.data.local.entity.LinkFtsEntity
import com.shaarit.data.local.entity.SyncStatus
@ -98,7 +99,7 @@ interface LinkDao {
@Query("""
SELECT * FROM links
WHERE is_dead_link = 1
WHERE link_check_status = 'BROKEN'
ORDER BY last_health_check DESC
""")
fun getDeadLinks(): PagingSource<Int, LinkEntity>
@ -106,14 +107,15 @@ interface LinkDao {
@Query("""
SELECT * FROM links
WHERE url NOT LIKE 'note://%'
AND excluded_from_health_check = 0
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)
@Query("UPDATE links SET link_check_status = :status, fail_count = :failCount, last_health_check = :timestamp WHERE id = :id")
suspend fun updateLinkHealthStatus(id: Int, status: LinkCheckStatus, failCount: Int, timestamp: Long)
// ====== Filtres temporels ======
@ -228,6 +230,35 @@ interface LinkDao {
@Query("SELECT * FROM links WHERE sync_status != 'PENDING_DELETE'")
suspend fun getAllLinksForStats(): List<LinkEntity>
// ====== Health Check Statistics ======
@Query("SELECT COUNT(*) FROM links WHERE url NOT LIKE 'note://%'")
fun getTotalBookmarksCount(): Flow<Int>
@Query("SELECT COUNT(*) FROM links WHERE url NOT LIKE 'note://%' AND last_health_check > 0")
fun getTestedBookmarksCount(): Flow<Int>
@Query("SELECT COUNT(*) FROM links WHERE link_check_status = 'BROKEN'")
fun getDeadLinksCount(): Flow<Int>
@Query("SELECT COUNT(*) FROM links WHERE link_check_status = 'PENDING'")
fun getPendingLinksCount(): Flow<Int>
@Query("SELECT MAX(last_health_check) FROM links WHERE last_health_check > 0")
fun getLastHealthCheckTime(): Flow<Long?>
@Query("SELECT * FROM links WHERE link_check_status = 'BROKEN' ORDER BY last_health_check DESC")
fun getDeadLinksFlow(): Flow<List<LinkEntity>>
@Query("SELECT * FROM links WHERE link_check_status = 'PENDING' ORDER BY fail_count DESC, last_health_check DESC")
fun getPendingLinksFlow(): Flow<List<LinkEntity>>
@Query("UPDATE links SET excluded_from_health_check = :excluded WHERE id = :linkId")
suspend fun updateHealthCheckExclusion(linkId: Int, excluded: Boolean)
@Query("UPDATE links SET excluded_from_health_check = :excluded WHERE id IN (:linkIds)")
suspend fun updateHealthCheckExclusionBatch(linkIds: List<Int>, excluded: Boolean)
}
data class SiteCount(

View File

@ -28,7 +28,7 @@ import com.shaarit.data.local.entity.TagEntity
CollectionEntity::class,
CollectionLinkCrossRef::class
],
version = 1,
version = 4,
exportSchema = false
)
@TypeConverters(Converters::class)

View File

@ -71,11 +71,17 @@ data class LinkEntity(
@ColumnInfo(name = "excerpt")
val excerpt: String? = null,
@ColumnInfo(name = "is_dead_link")
val isDeadLink: Boolean = false,
@ColumnInfo(name = "link_check_status")
val linkCheckStatus: LinkCheckStatus = LinkCheckStatus.VALID,
@ColumnInfo(name = "fail_count")
val failCount: Int = 0,
@ColumnInfo(name = "last_health_check")
val lastHealthCheck: Long = 0
val lastHealthCheck: Long = 0,
@ColumnInfo(name = "excluded_from_health_check")
val excludedFromHealthCheck: Boolean = false
)
/**
@ -108,6 +114,15 @@ enum class ContentType {
NEWS // News sites
}
/**
* Statut de vérification de lien
*/
enum class LinkCheckStatus {
VALID, // Le lien est accessible
PENDING, // En attente de confirmation (1-2 échecs)
BROKEN // Le lien est mort (>= 3 échecs consécutifs)
}
/**
* Entité FTS4 pour la recherche full-text
*/

View File

@ -9,15 +9,17 @@ import com.shaarit.domain.model.AiEnrichmentResult
import com.shaarit.domain.repository.GeminiRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class GeminiRepositoryImpl @Inject constructor(
private val tokenManager: TokenManager
private val tokenManager: TokenManager,
private val okHttpClient: OkHttpClient
) : GeminiRepository {
// Cache pour les tags
@ -122,7 +124,7 @@ class GeminiRepositoryImpl @Inject constructor(
return@withContext Result.failure(Exception("Clé API Gemini non configurée. Allez dans Paramètres pour la configurer."))
}
// Verify URL is accessible
// Verify URL is accessible using OkHttp
if (!isUrlAccessible(url)) {
return@withContext Result.failure(Exception("L'URL n'est pas accessible. Vérifiez qu'elle est valide."))
}
@ -210,22 +212,76 @@ class GeminiRepositoryImpl @Inject constructor(
return parseGeminiResponse(responseText).getOrThrow()
}
// Client dédié pour la vérification des liens (sans les intercepteurs de l'API Shaarli)
private val linkVerifierClient by lazy {
val builder = okHttpClient.newBuilder()
builder.interceptors().clear() // On retire les intercepteurs (Auth, HostSelection)
builder
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.retryOnConnectionFailure(true)
.build()
}
private fun isUrlAccessible(url: String): Boolean {
// User-Agent "Standard" pour éviter les blocages anti-bot
val userAgent = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
return try {
val connection = URL(url).openConnection() as HttpURLConnection
connection.requestMethod = "HEAD"
connection.connectTimeout = 5000
connection.readTimeout = 5000
connection.instanceFollowRedirects = true
val responseCode = connection.responseCode
connection.disconnect()
responseCode in 200..399
val request = Request.Builder()
.url(url)
.head() // HEAD request to check headers only
.header("User-Agent", userAgent)
.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.9")
.build()
linkVerifierClient.newCall(request).execute().use { response: Response ->
// Considérer comme un succès si le serveur répond, même avec une erreur
// Seules 404 (Not Found) et 410 (Gone) signifient vraiment que le lien est mort.
// 403 (Forbidden) = le lien existe mais on est bloqué (donc Vivant).
// 200..299 = Vivant.
response.code != 404 && response.code != 410
}
} catch (e: Exception) {
false
// Fallback: try GET if HEAD fails (some servers block HEAD or unexpected error)
try {
val request = Request.Builder()
.url(url)
.get()
.header("User-Agent", userAgent)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
.build()
linkVerifierClient.newCall(request).execute().use { response: Response ->
response.code != 404 && response.code != 410
}
} catch (e2: Exception) {
// Échec de connexion (DNS, Timeout, SSL Handshake fatal) -> Lien mort
false
}
}
}
private fun buildPrompt(url: String): String {
val specificInstructions = when {
url.contains("youtube.com") || url.contains("youtu.be") -> """
- C'est une vidéo YouTube. Essaie d'identifier le créateur de la vidéo.
- Si la description de la vidéo est disponible, utilise-la pour le résumé.
- Tags suggérés: youtube, video, [nom de la chaîne], [sujet principal]
""".trimIndent()
url.contains("github.com") || url.contains("gitlab.com") -> """
- C'est un dépôt de code. Essaie d'identifier le langage principal.
- Extrait l'objectif du projet depuis le README si visible.
- Tags suggérés: dev, open-source, [langage], [framework]
""".trimIndent()
else -> ""
}
return """
Rôle: Tu es un assistant expert en classification de contenu web pour l'application Shaarli.
@ -233,6 +289,8 @@ Tâche: Analyser les métadonnées de l'URL fournie pour extraire un titre, une
URL à analyser: $url
$specificInstructions
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.

View File

@ -538,10 +538,20 @@ constructor(
readingTime = readingTimeMinutes,
contentType = contentType.name,
siteName = siteName,
isDeadLink = isDeadLink
linkCheckStatus = linkCheckStatus.toDomainStatus(),
failCount = failCount,
lastHealthCheck = lastHealthCheck
)
}
private fun com.shaarit.data.local.entity.LinkCheckStatus.toDomainStatus(): com.shaarit.domain.model.LinkCheckStatus {
return when (this) {
com.shaarit.data.local.entity.LinkCheckStatus.VALID -> com.shaarit.domain.model.LinkCheckStatus.VALID
com.shaarit.data.local.entity.LinkCheckStatus.PENDING -> com.shaarit.domain.model.LinkCheckStatus.PENDING
com.shaarit.data.local.entity.LinkCheckStatus.BROKEN -> com.shaarit.domain.model.LinkCheckStatus.BROKEN
}
}
private fun LinkDto.toEntity(): LinkEntity? {
val linkId = id ?: return null
val linkUrl = url ?: return null

View File

@ -413,7 +413,11 @@ class SyncManager @Inject constructor(
readingTimeMinutes = existing?.readingTimeMinutes,
contentType = existing?.contentType ?: com.shaarit.data.local.entity.ContentType.UNKNOWN,
siteName = existing?.siteName,
excerpt = existing?.excerpt
excerpt = existing?.excerpt,
linkCheckStatus = existing?.linkCheckStatus ?: com.shaarit.data.local.entity.LinkCheckStatus.VALID,
failCount = existing?.failCount ?: 0,
lastHealthCheck = existing?.lastHealthCheck ?: 0,
excludedFromHealthCheck = existing?.excludedFromHealthCheck ?: false
)
} catch (e: Exception) {
Log.w(TAG, "Lien ignoré (id=${dto.id}): ${e.message}")

View File

@ -5,10 +5,16 @@ import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.entity.LinkCheckStatus
import com.shaarit.data.local.entity.LinkEntity
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.net.HttpURLConnection
import java.net.URL
@ -21,61 +27,166 @@ class LinkHealthCheckWorker @AssistedInject constructor(
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
private const val CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L // 12h
private const val TIMEOUT_MS = 7000 // 7 secondes (entre 5-10s recommandé)
private const val MAX_CONCURRENT_CHECKS = 10 // Limité pour éviter le rate limiting
private const val FAILURE_THRESHOLD = 3 // Nombre d'échecs avant de marquer comme BROKEN
// User-Agent moderne (Chrome sur Android)
private const val USER_AGENT = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
// Patterns d'exclusion (whitelist)
private val EXCLUDED_URL_PREFIXES = listOf(
"note://",
"http://shaare",
"/shaare",
"file://",
"localhost",
"127.0.0.1",
"192.168.",
"10.0.",
"172.16."
)
private val EXCLUDED_TAGS = setOf(
"note",
"#note",
"local-network",
"#local-network"
)
}
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)
val allLinks = linkDao.getLinksForHealthCheck(threshold, Int.MAX_VALUE)
if (allLinks.isEmpty()) {
return@withContext Result.success()
}
// Filtrage avec whitelist
val linksToCheck = allLinks.filter { link -> shouldCheckLink(link) }
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
// Rate limiting avec Semaphore
val semaphore = Semaphore(MAX_CONCURRENT_CHECKS)
// Vérification en parallèle avec concurrence limitée
linksToCheck.map { link ->
async {
semaphore.withPermit {
checkLinkStatus(link)
}
}
}.awaitAll()
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) {
e.printStackTrace()
Result.retry()
}
}
private fun shouldCheckLink(link: LinkEntity): Boolean {
// Exclure par URL
val urlLower = link.url.lowercase()
if (EXCLUDED_URL_PREFIXES.any { urlLower.startsWith(it) }) {
return false
}
// Exclure par tags
if (link.tags.any { tag -> EXCLUDED_TAGS.contains(tag.lowercase()) }) {
return false
}
return true
}
private suspend fun checkLinkStatus(link: LinkEntity) {
val isAccessible = isUrlAccessible(link.url)
val timestamp = System.currentTimeMillis()
if (isAccessible) {
// Succès: reset fail_count et marquer comme VALID
linkDao.updateLinkHealthStatus(
id = link.id,
status = LinkCheckStatus.VALID,
failCount = 0,
timestamp = timestamp
)
} else {
// Échec: incrémenter fail_count et déterminer le statut
handleFailure(link, timestamp)
}
}
private suspend fun handleFailure(link: LinkEntity, timestamp: Long) {
val newFailCount = link.failCount + 1
val newStatus = if (newFailCount >= FAILURE_THRESHOLD) {
LinkCheckStatus.BROKEN
} else {
LinkCheckStatus.PENDING
}
linkDao.updateLinkHealthStatus(
id = link.id,
status = newStatus,
failCount = newFailCount,
timestamp = timestamp
)
}
private fun isUrlAccessible(url: String): Boolean {
// Premier essai avec HEAD (économise bande passante)
if (checkUrl(url, "HEAD")) return true
// Fallback avec GET si HEAD échoue (certains serveurs bloquent HEAD)
return checkUrl(url, "GET")
}
private fun checkUrl(urlStr: String, method: String): Boolean {
var connection: HttpURLConnection? = null
return try {
val connection = URL(url).openConnection() as HttpURLConnection
connection.requestMethod = "HEAD"
val url = URL(urlStr)
connection = url.openConnection() as HttpURLConnection
connection.requestMethod = method
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()
// Headers pour simuler un vrai navigateur
connection.setRequestProperty("User-Agent", USER_AGENT)
connection.setRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
connection.setRequestProperty("Accept-Language", "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7")
connection.setRequestProperty("Accept-Encoding", "gzip, deflate, br")
connection.setRequestProperty("Connection", "keep-alive")
connection.setRequestProperty("Upgrade-Insecure-Requests", "1")
connection.connect()
responseCode in 200..399 || responseCode == 405
val responseCode = connection.responseCode
// Codes considérés comme "valides" (le serveur répond)
// 2xx: Succès
// 3xx: Redirection
// 401/403: Protégé mais existe
// 405: Méthode non autorisée mais existe
// 429: Rate limited mais existe
responseCode in 200..399 ||
responseCode == 401 ||
responseCode == 403 ||
responseCode == 405 ||
responseCode == 429
} catch (e: Exception) {
// Timeout, DNS error, SSL error, etc. -> échec
false
} finally {
connection?.disconnect()
}
}
}

View File

@ -15,5 +15,85 @@ data class ShaarliLink(
val readingTime: Int? = null,
val contentType: String? = null,
val siteName: String? = null,
val isDeadLink: Boolean = false
)
val linkCheckStatus: LinkCheckStatus = LinkCheckStatus.VALID,
val failCount: Int = 0,
val lastHealthCheck: Long = 0
) {
/**
* Statut de santé de l'URL
* - UNTESTED: jamais vérifié (lastHealthCheck == 0)
* - OK: vérifié et fonctionnel (VALID)
* - PENDING: en attente de confirmation (1-2 échecs)
* - DEAD: vérifié et mort (>= 3 échecs)
*/
val healthStatus: HealthStatus
get() = when {
url.startsWith("note://") || url.startsWith("/shaare/") -> HealthStatus.NOTE
lastHealthCheck == 0L -> HealthStatus.UNTESTED
linkCheckStatus == LinkCheckStatus.BROKEN -> HealthStatus.DEAD
linkCheckStatus == LinkCheckStatus.PENDING -> HealthStatus.PENDING
else -> HealthStatus.OK
}
/**
* Détermine si le bookmark est une note
*/
val isNote: Boolean
get() = url.startsWith("note://") ||
url.startsWith("http://shaare") ||
url.startsWith("/shaare") ||
tags.any { it.lowercase() == "note" || it.lowercase() == "#note" }
/**
* Détermine si le bookmark est un lien réseau local
*/
val isLocalNetwork: Boolean
get() {
val urlLower = url.lowercase()
return urlLower.contains("localhost") ||
urlLower.contains("127.0.0.1") ||
urlLower.contains("192.168.") ||
urlLower.contains("10.0.") ||
urlLower.contains("172.16.") ||
tags.any { it.lowercase() == "local-network" || it.lowercase() == "#local-network" }
}
/**
* Titre avec emoji de statut
* - 📝 pour les notes
* - 🌐 pour les réseaux locaux
* - 🔴 pour les liens morts (BROKEN)
* - 🟢 pour les liens fonctionnels (VALID après vérification)
*/
val displayTitle: String
get() {
val emoji = when {
isNote -> "📝"
isLocalNetwork -> "🌐"
linkCheckStatus == LinkCheckStatus.BROKEN -> "🔴"
linkCheckStatus == LinkCheckStatus.VALID && lastHealthCheck > 0 -> "🟢"
else -> null
}
return if (emoji != null) {
// Éviter les doublons si l'emoji est déjà présent
if (title.startsWith(emoji)) title else "$emoji $title"
} else {
title
}
}
}
enum class LinkCheckStatus {
VALID, // Le lien est accessible
PENDING, // En attente de confirmation (1-2 échecs)
BROKEN // Le lien est mort (>= 3 échecs consécutifs)
}
enum class HealthStatus {
NOTE, // C'est une note, pas un lien
UNTESTED, // Jamais testé
OK, // Testé et fonctionnel
PENDING, // En attente de confirmation
DEAD // Testé et mort
}

View File

@ -299,11 +299,22 @@ constructor(
}
}
var finalUrl = currentUrl
var finalTitle = title.value
if (_contentTypeSelection.value == ContentType.NOTE) {
if (finalUrl.isBlank()) {
finalUrl = "/shaare/${generateRandomId()}"
}
if (finalTitle.isNotBlank() && !finalTitle.startsWith("Note: ", ignoreCase = true)) {
finalTitle = "Note: $finalTitle"
}
}
val result =
linkRepository.addOrUpdateLink(
url = if (_contentTypeSelection.value == ContentType.NOTE && currentUrl.isBlank())
"note://local/${System.currentTimeMillis()}" else currentUrl,
title = title.value.ifBlank { null },
url = finalUrl,
title = finalTitle.ifBlank { null },
description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value,
@ -353,11 +364,22 @@ constructor(
_uiState.value = AddLinkUiState.Loading
var finalUrl = currentUrl
var finalTitle = title.value
if (_contentTypeSelection.value == ContentType.NOTE) {
if (finalUrl.isBlank()) {
finalUrl = "/shaare/${generateRandomId()}"
}
if (finalTitle.isNotBlank() && !finalTitle.startsWith("Note: ", ignoreCase = true)) {
finalTitle = "Note: $finalTitle"
}
}
val result =
linkRepository.addOrUpdateLink(
url = if (_contentTypeSelection.value == ContentType.NOTE && currentUrl.isBlank())
"note://local/${System.currentTimeMillis()}" else currentUrl,
title = title.value.ifBlank { null },
url = finalUrl,
title = finalTitle.ifBlank { null },
description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value,
@ -394,6 +416,13 @@ constructor(
removeTag("note")
}
}
private fun generateRandomId(length: Int = 6): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
return (1..length)
.map { chars.random() }
.joinToString("")
}
}
sealed class AddLinkUiState {

View File

@ -2,13 +2,17 @@ package com.shaarit.presentation.deadlinks
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
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.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -34,6 +38,8 @@ fun DeadLinksScreen(
val pagingItems = viewModel.pagedDeadLinks.collectAsLazyPagingItems()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val selectedLinkIds by viewModel.selectedLinkIds.collectAsState()
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
Box(
modifier = Modifier
@ -50,21 +56,42 @@ fun DeadLinksScreen(
TopAppBar(
title = {
Text(
"Liens inaccessibles",
if (isSelectionMode) "${selectedLinkIds.size} sélectionné(s)" else "Liens inaccessibles",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
IconButton(onClick = {
if (isSelectionMode) {
viewModel.clearSelection()
} else {
onNavigateBack()
}
}) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Retour",
if (isSelectionMode) Icons.Default.Close else Icons.Default.ArrowBack,
contentDescription = if (isSelectionMode) "Annuler" else "Retour",
tint = TextPrimary
)
}
},
actions = {
if (isSelectionMode) {
IconButton(
onClick = {
viewModel.excludeSelectedFromHealthCheck()
}
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = "Exclure de la vérification",
tint = SuccessGreen
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = DeepNavy.copy(alpha = 0.9f),
titleContentColor = TextPrimary
@ -114,15 +141,23 @@ fun DeadLinksScreen(
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,
// Note: Pour l'instant ListViewItem ne gère pas linkCheckStatus visuellement,
// on va devoir le modifier ou wrapper l'item.
// Le viewmodel gère la suppression.
ListViewItem(
DeadLinkItem(
link = link,
isSelected = selectedLinkIds.contains(link.id),
onItemClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
context.startActivity(intent)
if (isSelectionMode) {
viewModel.toggleSelection(link.id)
} else {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
context.startActivity(intent)
}
},
onLongClick = {
viewModel.toggleSelection(link.id)
},
onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
@ -130,7 +165,6 @@ fun DeadLinksScreen(
},
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) },
// Pas de tags clickables ici pour simplifier
onTagClick = { },
onViewClick = { }
)
@ -142,3 +176,39 @@ fun DeadLinksScreen(
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DeadLinkItem(
link: com.shaarit.domain.model.ShaarliLink,
isSelected: Boolean,
onItemClick: () -> Unit,
onLongClick: () -> Unit,
onLinkClick: (String) -> Unit,
onEditClick: (Int) -> Unit,
onDeleteClick: (Int) -> Unit,
onTagClick: (String) -> Unit,
onViewClick: (Int) -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onItemClick,
onLongClick = onLongClick
),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) CyanPrimary.copy(alpha = 0.1f) else CardBackground
)
) {
ListViewItem(
link = link,
onItemClick = onItemClick,
onLinkClick = onLinkClick,
onEditClick = { onEditClick(link.id) },
onDeleteClick = { onDeleteClick(link.id) },
onTagClick = onTagClick,
onViewClick = { onViewClick(link.id) }
)
}
}

View File

@ -4,22 +4,62 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.shaarit.data.local.dao.LinkDao
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class DeadLinksViewModel @Inject constructor(
private val linkRepository: LinkRepository
private val linkRepository: LinkRepository,
private val linkDao: LinkDao
) : ViewModel() {
val pagedDeadLinks: Flow<PagingData<ShaarliLink>> =
linkRepository.getDeadLinksStream()
.cachedIn(viewModelScope)
private val _selectedLinkIds = MutableStateFlow<Set<Int>>(emptySet())
val selectedLinkIds: StateFlow<Set<Int>> = _selectedLinkIds.asStateFlow()
private val _isSelectionMode = MutableStateFlow(false)
val isSelectionMode: StateFlow<Boolean> = _isSelectionMode.asStateFlow()
fun toggleSelection(linkId: Int) {
_selectedLinkIds.value = if (_selectedLinkIds.value.contains(linkId)) {
_selectedLinkIds.value - linkId
} else {
_selectedLinkIds.value + linkId
}
if (_selectedLinkIds.value.isEmpty()) {
_isSelectionMode.value = false
} else if (!_isSelectionMode.value) {
_isSelectionMode.value = true
}
}
fun clearSelection() {
_selectedLinkIds.value = emptySet()
_isSelectionMode.value = false
}
fun excludeSelectedFromHealthCheck() {
viewModelScope.launch {
val ids = _selectedLinkIds.value.toList()
if (ids.isNotEmpty()) {
linkDao.updateHealthCheckExclusionBatch(ids, true)
clearSelection()
}
}
}
fun deleteLink(id: Int) {
viewModelScope.launch {
linkRepository.deleteLink(id)

View File

@ -324,11 +324,22 @@ constructor(
}
}
var finalUrl = currentUrl
var finalTitle = currentTitle
if (_contentType.value == ContentType.NOTE) {
if (finalUrl.isBlank()) {
finalUrl = "/shaare/${generateRandomId()}"
}
if (finalTitle.isNotBlank() && !finalTitle.startsWith("Note: ", ignoreCase = true)) {
finalTitle = "Note: $finalTitle"
}
}
linkRepository.updateLink(
id = linkId,
url = if (_contentType.value == ContentType.NOTE && currentUrl.isBlank())
"note://local/${System.currentTimeMillis()}" else currentUrl,
title = currentTitle.ifBlank { null },
url = finalUrl,
title = finalTitle.ifBlank { null },
description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value
@ -342,6 +353,13 @@ constructor(
)
}
}
private fun generateRandomId(length: Int = 6): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
return (1..length)
.map { chars.random() }
.joinToString("")
}
}
sealed class EditLinkUiState {

View File

@ -44,6 +44,10 @@ import com.shaarit.domain.model.ViewStyle
import com.shaarit.ui.components.PremiumTextField
import com.shaarit.ui.components.TagChip
import com.shaarit.ui.theme.*
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import kotlinx.coroutines.launch
// ============== Accordion Section Component ==============
@ -288,6 +292,7 @@ fun FeedScreen(
val tags by viewModel.tags.collectAsState()
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
var showViewStyleMenu by remember { mutableStateOf(false) }
@ -312,6 +317,7 @@ fun FeedScreen(
val pullRefreshState = rememberPullRefreshState(
refreshing = pagingItems.loadState.refresh is LoadState.Loading,
onRefresh = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
viewModel.refresh()
pagingItems.refresh()
}
@ -784,6 +790,7 @@ fun FeedScreen(
// Refresh Button
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
viewModel.refresh()
pagingItems.refresh()
}) {
@ -1332,10 +1339,7 @@ fun FeedScreen(
when {
pagingItems.loadState.refresh is LoadState.Loading && pagingItems.itemCount == 0 -> {
// Initial loading
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) { CircularProgressIndicator(color = CyanPrimary) }
com.shaarit.ui.components.SkeletonLinkList(modifier = Modifier.fillMaxSize())
}
pagingItems.loadState.refresh is LoadState.Error && pagingItems.itemCount == 0 -> {
Box(
@ -1412,6 +1416,7 @@ fun FeedScreen(
}
},
onItemLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (!selectionMode) {
selectionMode = true
selectedIds = setOf(link.id)
@ -1471,6 +1476,7 @@ fun FeedScreen(
}
},
onItemLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (!selectionMode) {
selectionMode = true
selectedIds = setOf(link.id)
@ -1530,6 +1536,7 @@ fun FeedScreen(
}
},
onItemLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (!selectionMode) {
selectionMode = true
selectedIds = setOf(link.id)
@ -1581,18 +1588,25 @@ fun FeedScreen(
contentColor = CyanPrimary
)
}
}
}
}
if (selectedLink != null) {
LinkDetailsDialog(
link = selectedLink!!,
onDismiss = { selectedLink = null },
onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
}
)
// Link Details Overlay (Hero-like transition)
AnimatedVisibility(
visible = selectedLink != null,
enter = fadeIn(animationSpec = tween(300)) + scaleIn(initialScale = 0.9f, animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(200)) + scaleOut(targetScale = 0.9f, animationSpec = tween(200)),
modifier = Modifier.fillMaxSize()
) {
selectedLink?.let { link ->
LinkDetailsView(
link = link,
onDismiss = { selectedLink = null },
onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
}
)
}
}
if (showAddToCollectionDialog) {
@ -1633,5 +1647,6 @@ fun FeedScreen(
}
)
}
}
}
}

View File

@ -18,6 +18,9 @@ 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.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.HelpOutline
import androidx.compose.material.icons.filled.Warning
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.window.DialogProperties
@ -30,11 +33,15 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.shaarit.domain.model.HealthStatus
import com.shaarit.domain.model.ShaarliLink
import com.shaarit.ui.components.GlassCard
import com.shaarit.ui.components.TagChip
import com.shaarit.ui.theme.*
import dev.jeziellago.compose.markdowntext.MarkdownText
import coil.compose.AsyncImage
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Color
/**
* Full list view item - shows all details including markdown description
@ -58,7 +65,7 @@ fun ListViewItem(
if (showDeleteDialog) {
DeleteConfirmationDialog(
linkTitle = link.title,
linkTitle = link.displayTitle,
onConfirm = {
onDeleteClick()
showDeleteDialog = false
@ -76,12 +83,24 @@ fun ListViewItem(
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.Top
) {
// Thumbnail (List View)
if (!link.thumbnailUrl.isNullOrBlank()) {
AsyncImage(
model = link.thumbnailUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(100.dp)
.clip(RoundedCornerShape(12.dp))
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = link.title,
text = link.displayTitle,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = CyanPrimary,
@ -91,18 +110,18 @@ fun ListViewItem(
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)
)
}
HealthStatusIcon(
healthStatus = link.healthStatus,
modifier = Modifier.size(16.dp).padding(end = 4.dp)
)
Text(
text = link.url,
style = MaterialTheme.typography.bodySmall,
color = if (link.isDeadLink) ErrorRed else TealSecondary,
color = when (link.healthStatus) {
HealthStatus.DEAD -> ErrorRed
HealthStatus.OK -> TealSecondary
else -> TextMuted
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@ -230,7 +249,7 @@ fun GridViewItem(
if (showDeleteDialog) {
DeleteConfirmationDialog(
linkTitle = link.title,
linkTitle = link.displayTitle,
onConfirm = {
onDeleteClick()
showDeleteDialog = false
@ -242,7 +261,7 @@ fun GridViewItem(
GlassCard(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
.heightIn(min = 220.dp),
onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() },
onLongClick = onItemLongClick,
glowColor = if (isSelected) CyanPrimary else CyanPrimary
@ -252,26 +271,40 @@ fun GridViewItem(
verticalArrangement = Arrangement.SpaceBetween
) {
Column {
// Thumbnail (Grid View)
if (!link.thumbnailUrl.isNullOrBlank()) {
AsyncImage(
model = link.thumbnailUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
.padding(bottom = 12.dp)
.clip(RoundedCornerShape(12.dp))
)
}
// Title with pin indicator
Row(
verticalAlignment = Alignment.Top,
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)
)
}
HealthStatusIcon(
healthStatus = link.healthStatus,
modifier = Modifier.size(16.dp).padding(end = 4.dp).align(Alignment.CenterVertically)
)
Text(
text = link.title,
text = link.displayTitle,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = if (link.isDeadLink) ErrorRed else CyanPrimary,
color = when (link.healthStatus) {
HealthStatus.DEAD -> ErrorRed
HealthStatus.OK -> CyanPrimary
else -> CyanPrimary.copy(alpha = 0.7f)
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
@ -431,7 +464,7 @@ fun CompactViewItem(
if (showDeleteDialog) {
DeleteConfirmationDialog(
linkTitle = link.title,
linkTitle = link.displayTitle,
onConfirm = {
onDeleteClick()
showDeleteDialog = false
@ -489,19 +522,19 @@ fun CompactViewItem(
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)
)
}
HealthStatusIcon(
healthStatus = link.healthStatus,
modifier = Modifier.size(14.dp).padding(end = 4.dp)
)
Text(
text = link.title,
text = link.displayTitle,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (link.isDeadLink) ErrorRed else CyanPrimary,
color = when (link.healthStatus) {
HealthStatus.DEAD -> ErrorRed
HealthStatus.OK -> CyanPrimary
else -> CyanPrimary.copy(alpha = 0.7f)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@ -615,21 +648,33 @@ fun DeleteConfirmationDialog(
/**
* Dialog to show full link details
*/
/**
* Full screen link details view (not a dialog window, so we can animate it)
*/
/**
* Full screen link details view (not a dialog window, so we can animate it)
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun LinkDetailsDialog(
fun LinkDetailsView(
link: ShaarliLink,
onDismiss: () -> Unit,
onLinkClick: (String) -> Unit
) {
androidx.compose.ui.window.Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
Box(
modifier = Modifier
.fillMaxSize()
.clickable(onClick = onDismiss) // Click outside to dismiss
.background(Color.Black.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
) {
// Stop propagation of clicks to the background
GlassCard(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.fillMaxWidth()
.fillMaxHeight(0.9f)
.clickable(enabled = false, onClick = {})
) {
Column(
modifier = Modifier
@ -643,7 +688,7 @@ fun LinkDetailsDialog(
verticalAlignment = Alignment.Top
) {
Text(
text = link.title,
text = link.displayTitle,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = CyanPrimary,
@ -665,6 +710,20 @@ fun LinkDetailsDialog(
Spacer(modifier = Modifier.height(16.dp))
// Hero Image in Details
if (!link.thumbnailUrl.isNullOrBlank()) {
AsyncImage(
model = link.thumbnailUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(16.dp))
)
Spacer(modifier = Modifier.height(16.dp))
}
// Scrollable Content
Column(
modifier = Modifier
@ -754,3 +813,54 @@ fun LinkDetailsDialog(
}
}
}
/**
* Indicateur visuel du statut de santé d'un lien
* - NOTE: pas d'icône (c'est une note, pas un lien)
* - UNTESTED: icône grise (jamais testé)
* - OK: icône verte (testé et fonctionnel)
* - DEAD: icône rouge (testé et mort)
*/
@Composable
fun HealthStatusIcon(
healthStatus: HealthStatus,
modifier: Modifier = Modifier
) {
when (healthStatus) {
HealthStatus.NOTE -> {
// Pas d'icône pour les notes
}
HealthStatus.UNTESTED -> {
Icon(
imageVector = Icons.Default.HelpOutline,
contentDescription = "Non testé",
tint = TextMuted,
modifier = modifier
)
}
HealthStatus.OK -> {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = "Lien fonctionnel",
tint = SuccessGreen,
modifier = modifier
)
}
HealthStatus.PENDING -> {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = "En attente de confirmation",
tint = Color(0xFFFFA726), // Orange
modifier = modifier
)
}
HealthStatus.DEAD -> {
Icon(
imageVector = Icons.Default.BrokenImage,
contentDescription = "Lien mort",
tint = ErrorRed,
modifier = modifier
)
}
}
}

View File

@ -104,6 +104,7 @@ fun AppNavGraph(
},
navArgument("collectionId") {
type = NavType.LongType
nullable = false
defaultValue = -1L
}
),

View File

@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.LinkOff
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material3.*
import androidx.compose.runtime.*
@ -225,6 +226,29 @@ fun SettingsScreen(
onClick = { viewModel.scanAndClassify() }
)
}
// Health Check Section
item {
Spacer(modifier = Modifier.height(16.dp))
SettingsSection(title = "Vérification des liens")
}
item {
val totalBookmarks by viewModel.totalBookmarks.collectAsState()
val testedBookmarks by viewModel.testedBookmarks.collectAsState()
val deadLinksCount by viewModel.deadLinksCount.collectAsState()
val lastHealthCheckTime by viewModel.lastHealthCheckTime.collectAsState()
val isHealthCheckRunning by viewModel.isHealthCheckRunning.collectAsState()
HealthCheckStatusItem(
totalBookmarks = totalBookmarks,
testedBookmarks = testedBookmarks,
deadLinksCount = deadLinksCount,
lastHealthCheckTime = lastHealthCheckTime,
isRunning = isHealthCheckRunning,
onStartCheck = { viewModel.triggerHealthCheck() }
)
}
// About Section
item {
@ -540,3 +564,107 @@ private fun GeminiApiKeyItem(
}
}
}
@Composable
private fun HealthCheckStatusItem(
totalBookmarks: Int,
testedBookmarks: Int,
deadLinksCount: Int,
lastHealthCheckTime: Long?,
isRunning: Boolean,
onStartCheck: () -> Unit
) {
val dateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault())
val lastCheckText = if (lastHealthCheckTime != null && lastHealthCheckTime > 0) {
dateFormat.format(Date(lastHealthCheckTime))
} else {
"Jamais"
}
val progressPercent = if (totalBookmarks > 0) {
(testedBookmarks * 100) / totalBookmarks
} else {
0
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onStartCheck, enabled = !isRunning)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (isRunning) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = Icons.Outlined.LinkOff,
contentDescription = null,
tint = if (deadLinksCount > 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (isRunning) "Vérification en cours..." else "Vérifier les liens",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "Dernier cycle: $lastCheckText",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Progress bar
LinearProgressIndicator(
progress = progressPercent / 100f,
modifier = Modifier
.fillMaxWidth()
.height(8.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
// Stats row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "$testedBookmarks / $totalBookmarks testés ($progressPercent%)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (deadLinksCount > 0) {
Text(
text = "⚠️ $deadLinksCount liens morts",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
} else if (testedBookmarks > 0) {
Text(
text = "✓ Tous fonctionnels",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}

View File

@ -9,6 +9,7 @@ import com.shaarit.data.export.BookmarkImporter
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.sync.SyncManager
import com.shaarit.data.sync.SyncState
import com.shaarit.data.worker.LinkHealthCheckWorker
import com.shaarit.domain.usecase.ClassifyBookmarksUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
@ -16,6 +17,9 @@ import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
import androidx.work.WorkManager
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
@HiltViewModel
class SettingsViewModel @Inject constructor(
@ -24,7 +28,8 @@ class SettingsViewModel @Inject constructor(
private val syncManager: SyncManager,
private val linkDao: LinkDao,
private val classifyBookmarksUseCase: ClassifyBookmarksUseCase,
private val tokenManager: TokenManager
private val tokenManager: TokenManager,
private val workManager: WorkManager
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
@ -36,8 +41,28 @@ class SettingsViewModel @Inject constructor(
private val _geminiApiKey = MutableStateFlow(tokenManager.getGeminiApiKey() ?: "")
val geminiApiKey: StateFlow<String> = _geminiApiKey.asStateFlow()
// Health Check Statistics
val totalBookmarks: StateFlow<Int> = linkDao.getTotalBookmarksCount()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
val testedBookmarks: StateFlow<Int> = linkDao.getTestedBookmarksCount()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
val deadLinksCount: StateFlow<Int> = linkDao.getDeadLinksCount()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
val pendingLinksCount: StateFlow<Int> = linkDao.getPendingLinksCount()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
val lastHealthCheckTime: StateFlow<Long?> = linkDao.getLastHealthCheckTime()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
private val _isHealthCheckRunning = MutableStateFlow(false)
val isHealthCheckRunning: StateFlow<Boolean> = _isHealthCheckRunning.asStateFlow()
init {
observeSyncStatus()
observeHealthCheckWork()
}
private fun observeSyncStatus() {
@ -62,6 +87,33 @@ class SettingsViewModel @Inject constructor(
}
}
private fun observeHealthCheckWork() {
viewModelScope.launch {
workManager.getWorkInfosForUniqueWorkFlow(LinkHealthCheckWorker.WORK_NAME)
.collect { workInfos ->
_isHealthCheckRunning.value = workInfos.any {
it.state == androidx.work.WorkInfo.State.RUNNING
}
}
}
}
fun triggerHealthCheck() {
viewModelScope.launch {
_isHealthCheckRunning.value = true
_uiState.value = _uiState.value.copy(message = "Vérification des liens en cours...")
val request = OneTimeWorkRequestBuilder<LinkHealthCheckWorker>()
.build()
workManager.enqueueUniqueWork(
"health_check_manual",
ExistingWorkPolicy.REPLACE,
request
)
}
}
fun exportToJson(uri: Uri) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)

View File

@ -0,0 +1,171 @@
package com.shaarit.ui.components
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.shaarit.ui.theme.CardBackground
import com.shaarit.ui.theme.CardBackgroundElevated
fun Modifier.shimmerEffect(): Modifier = composed {
val transition = rememberInfiniteTransition(label = "shimmer")
val translateAnimation = transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1000,
easing = FastOutSlowInEasing
),
repeatMode = RepeatMode.Reverse
), label = "shimmer_float"
)
val brush = Brush.linearGradient(
colors = listOf(
Color.White.copy(alpha = 0.05f),
Color.White.copy(alpha = 0.2f),
Color.White.copy(alpha = 0.05f),
),
start = Offset.Zero,
end = Offset(x = translateAnimation.value, y = translateAnimation.value)
)
this.background(brush)
}
@Composable
fun SkeletonLinkCard(
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(16.dp)), // Match GlassCard shape
color = CardBackground.copy(alpha = 0.5f)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
Column {
// Title Area
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Box(
modifier = Modifier
.weight(1f)
.height(20.dp)
.clip(RoundedCornerShape(4.dp))
.shimmerEffect()
)
Spacer(modifier = Modifier.width(16.dp))
Box(
modifier = Modifier
.size(16.dp)
.clip(RoundedCornerShape(4.dp))
.shimmerEffect()
)
}
Spacer(modifier = Modifier.height(12.dp))
// Description lines
Box(
modifier = Modifier
.fillMaxWidth(0.9f)
.height(14.dp)
.clip(RoundedCornerShape(4.dp))
.shimmerEffect()
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth(0.7f)
.height(14.dp)
.clip(RoundedCornerShape(4.dp))
.shimmerEffect()
)
}
Column {
// Tags
Row(modifier = Modifier.padding(bottom = 12.dp)) {
Box(
modifier = Modifier
.width(60.dp)
.height(20.dp)
.clip(RoundedCornerShape(10.dp))
.shimmerEffect()
)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.width(40.dp)
.height(20.dp)
.clip(RoundedCornerShape(10.dp))
.shimmerEffect()
)
}
// Footer (Date, Actions)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.width(80.dp)
.height(12.dp)
.clip(RoundedCornerShape(4.dp))
.shimmerEffect()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
repeat(3) {
Box(
modifier = Modifier
.size(24.dp)
.clip(RoundedCornerShape(12.dp))
.shimmerEffect()
)
}
}
}
}
}
}
}
@Composable
fun SkeletonLinkList(
modifier: Modifier = Modifier
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(8) {
SkeletonLinkCard()
}
}
}

View File

@ -1,67 +0,0 @@
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:mergeDebugNativeDebugMetadata NO-SOURCE
> Task :app:checkKotlinGradlePluginConfigurationErrors
> Task :app:generateDebugResValues UP-TO-DATE
> Task :app:mapDebugSourceSetPaths
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:checkDebugAarMetadata
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:javaPreCompileDebug UP-TO-DATE
> Task :app:mergeDebugShaders UP-TO-DATE
> Task :app:compileDebugShaders NO-SOURCE
> Task :app:generateDebugAssets UP-TO-DATE
> Task :app:mergeDebugAssets UP-TO-DATE
> Task :app:compressDebugAssets UP-TO-DATE
> Task :app:mergeDebugResources
> Task :app:checkDebugDuplicateClasses
> Task :app:desugarDebugFileDependencies UP-TO-DATE
> Task :app:processDebugResources
> Task :app:mergeExtDexDebug
> Task :app:mergeLibDexDebug UP-TO-DATE
> Task :app:mergeDebugJniLibFolders UP-TO-DATE
> Task :app:mergeDebugNativeLibs NO-SOURCE
> Task :app:stripDebugDebugSymbols NO-SOURCE
> Task :app:validateSigningDebug UP-TO-DATE
> Task :app:writeDebugAppMetadata UP-TO-DATE
> Task :app:writeDebugSigningConfigVersions UP-TO-DATE
> Task :app:kspDebugKotlin
> Task :app:compileDebugKotlin
w: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/MainActivity.kt:23:13 Variable 'splashScreen' is never used
w: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/data/repository/AuthRepositoryImpl.kt:23:17 Variable 'info' is never used
w: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt:103:17 'MarkdownText(String, Modifier = ..., Color = ..., Color = ..., TextUnit = ..., TextAlign? = ..., Boolean = ..., TextUnit = ..., Int = ..., Boolean = ..., AutoSizeConfig? = ..., Int? = ..., TextStyle = ..., Int? = ..., (() -> Unit)? = ..., Boolean = ..., ImageLoader? = ..., Int = ..., ((String) -> Unit)? = ..., ((numLines: Int) -> Unit)? = ...): Unit' is deprecated. The parameters `color`, `fontSize`, `textAlign` and `lineHeight` must be part of TextStyle.
w: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt:163:5 Parameter 'onTagClick' is never used
w: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt:206:21 'MarkdownText(String, Modifier = ..., Color = ..., Color = ..., TextUnit = ..., TextAlign? = ..., Boolean = ..., TextUnit = ..., Int = ..., Boolean = ..., AutoSizeConfig? = ..., Int? = ..., TextStyle = ..., Int? = ..., (() -> Unit)? = ..., Boolean = ..., ImageLoader? = ..., Int = ..., ((String) -> Unit)? = ..., ((numLines: Int) -> Unit)? = ...): Unit' is deprecated. The parameters `color`, `fontSize`, `textAlign` and `lineHeight` must be part of TextStyle.
w: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt:303:5 Parameter 'onTagClick' is never used
w: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt:35:9 Variable 'isShareIntent' is never used
w: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/ui/theme/Theme.kt:94:25 Variable 'context' is never used
> Task :app:compileDebugJavaWithJavac
> Task :app:hiltAggregateDepsDebug
WARNING: [Processor] Library 'C:\Users\bruno\scoop\apps\gradle\current\.gradle\caches\transforms-3\a425a2587f13f7b164b81c94b4e3e193\transformed\core-1.12.0-api.jar' contains references to both AndroidX and old support library. This seems like the library is partially migrated. Jetifier will try to rewrite the library anyway.
Example of androidX reference: 'androidx/core/os/BuildCompat'
Example of support library reference: 'android/support/v4/app/INotificationSideChannel$Default'
> Task :app:hiltJavaCompileDebug
warning: Kapt support in Moshi Kotlin Code Gen is deprecated and will be removed in 2.0. Please migrate to KSP. https://github.com/square/moshi#codegen
1 warning
> Task :app:processDebugJavaRes
> Task :app:transformDebugClassesWithAsm
> Task :app:mergeDebugJavaResource
> Task :app:dexBuilderDebug
> Task :app:mergeProjectDexDebug
> Task :app:packageDebug
> Task :app:createDebugApkListingFileRedirect
> Task :app:assembleDebug
BUILD SUCCESSFUL in 2m 16s
37 actionable tasks: 20 executed, 17 up-to-date

View File

@ -1,44 +0,0 @@
> Task :app:checkKotlinGradlePluginConfigurationErrors
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:kspDebugKotlin
> Task :app:compileDebugKotlin FAILED
e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:16:47 Unresolved reference: ViewModule
e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:17:47 Unresolved reference: ViewStream
e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:18:47 Unresolved reference: ViewList
e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:82:77 Unresolved reference: ViewStream
e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:83:77 Unresolved reference: ViewModule
e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:84:80 Unresolved reference: ViewList
e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:103:67 Unresolved reference: ViewStream
e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:125:67 Unresolved reference: ViewModule
e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:147:67 Unresolved reference: ViewList
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':app:compileDebugKotlin'.
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
> Compilation error. See log for more details
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
BUILD FAILED in 12s
15 actionable tasks: 3 executed, 12 up-to-date

View File

@ -0,0 +1,94 @@
# 🚀 ShaarIt - Rapport d'Audit & Plan d'Action "Next Level"
Ce document récapitule l'analyse de l'état actuel du projet et propose une série d'améliorations ciblées pour faire passer l'application d'un "excellent projet" à un "produit d'exception".
**Date de l'audit** : 31 Janvier 2026
**Version ciblée** : Codebase actuelle (v1.0-dev)
---
## 1. 📊 État des Lieux
Le projet est techniquement très solide et suit les standards modernes Android :
- **Architecture** : Clean Architecture (Presentation, Domain, Data) avec Hilt.
- **UI** : Jetpack Compose pur, Material 3, Thème personnalisé "Premium" (Cyan/DeepNavy).
- **Offline-first** : Implémentation Room complète avec SyncManager robuste.
- **Intelligence** : Intégration de Gemini (Flash 2.5) pour l'auto-tagging et l'extraction de métadonnées.
- **Fonctionnalités** : Support Markdown (éditeur + rendu), Filtres avancés, Collections.
**Verdict** : Les bases sont excellentes. "Le niveau supérieur" ne réside pas dans la refonte, mais dans le **raffinement (Polish)** et l'**Expérience Utilisateur (UX)**.
---
## 2. 💎 Propositions d'Améliorations (Le "Next Level")
Pour atteindre un niveau de qualité "Apple-like" ou "Top Tier SaaS", voici les axes prioritaires :
### 🎨 Look & Feel (Wow Factor)
| Amélioration | Description | Impact |
|--------------|-------------|--------|
| **Micro-Interactions** | Ajouter du retour haptique (`HapticFeedback`) lors des actions clés (Long press, Toggle, Save). | ⭐⭐⭐⭐⭐ (Ergonomie) |
| **Hero Transitions** | Animer la transition entre la liste et le détail d'un lien (l'image s'agrandit, le titre glisse). | ⭐⭐⭐⭐ (Visuel) |
| **Skeleton Loading** | Remplacer les indicateurs de chargement circulaires par des "Skeletons" (formes grises pulsantes) pour un rendu plus fluide. | ⭐⭐⭐⭐ (Perçu) |
| **Confetti/Feedback** | Une animation subtile lors de l'ajout réussi d'un lien ou de la complétion d'une tâche (Inbox Zero). | ⭐⭐⭐ (Plaisir) |
| **Adaptive Layouts** | Optimiser pour tablettes/foldables (Navigation Rail au lieu de Drawer) si l'écran est large. | ⭐⭐⭐ (Premium) |
### 🧠 Intelligence Artificielle (Raffinement)
L'implémentation actuelle de Gemini est fonctionnelle mais peut être optimisée :
1. **Nettoyage du Code** :
- *Problème* : `GeminiRepositoryImpl` utilise `HttpURLConnection` pour vérifier les liens.
- *Solution* : Utiliser le client `OkHttp` déjà injecté pour bénéficier du Connection Pooling et de la cohérence.
2. **Mode "Offline AI"** :
- *Idée* : Si Gemini échoue (réseau/quota), utiliser **ML Kit** (local) pour extraire du texte ou catégoriser basiquement.
3. **Prompt Engineering Contextuel** :
- Adapter le prompt selon que l'URL est une vidéo YouTube ou github (déjà partiellement fait, mais peut être affiné pour extraire les chapitres YouTube).
### ⚡ Performance & Technique
1. **Baseline Profiles** :
- Générer des Baseline Profiles pour réduire le temps de démarrage (très important pour une app de "capture rapide").
2. **Optimisation Images (Coil)** :
- S'assurer que les thumbnails sont redimensionnés par Coil en mémoire pour éviter d'allouer des bitmaps 4K pour des petites cartes.
3. **Strict Mode & Leaks** :
- Intégrer LeakCanary en debug pour s'assurer qu'aucune fuite de mémoire ne ralentit l'app sur la durée.
### 📱 Ergonomie & Intégration Système
1. **Quick Settings Tile** :
- Ajouter une tuile dans les réglages rapides d'Android pour "Ajouter le lien du presse-papier" sans ouvrir l'app.
2. **Voice Input** :
- Ajouter un bouton micro dans la barre de recherche et l'ajout de lien.
3. **Widget Interactif** :
- Si pas encore fait, le widget doit permettre de marquer un lien comme "Lu" ou de le copier sans ouvrir l'app.
4. **Share Target Optimisé** :
- Faire apparaître l'écran d'ajout *immédiatement* lors du partage depuis Chrome.
---
## 3. 📝 Plan d'Action Recommandé
Voici les tâches concrètes à intégrer :
### Priorité 1 : UX "Delight" (Rapide & Visible)
- [ ] **Haptics** : Ajouter `LocalHapticFeedback.current` dans `FeedScreen` et `LinkItem`.
- [ ] **Animations** : Implémenter `AnimatedContent` pour les changements d'état (filtres, recherche).
- [ ] **Refactor Réseau** : Remplacer `HttpURLConnection` par `OkHttp` dans `GeminiRepositoryImpl`.
### Priorité 2 : Intelligence & Fonctionnel
- [ ] **Voice Search** : Ajouter la reconnaissance vocale dans la SearchBar.
- [ ] **Quick Tile** : Créer un `TileService` pour l'ajout rapide depuis le clipboard.
### Priorité 3 : Performance & Solidité
- [ ] **Baseline Profiles** : Configurer le module benchmark.
- [ ] **Tests UI** : Ajouter des tests instrumentés pour les parcours critiques (Ajout lien -> List).
---
## 4. 💡 Idée Bonus : "The ShaarIt Daily"
Créer une vue "Daily Briefing" qui utilise Gemini pour :
- Résumer les 3 articles les plus intéressants ajoutés hier.
- Suggérer 1 "vieux lien" oublié à redécouvrir.
Ce serait une fonctionnalité "Signature" unique à votre application.