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 android.app.Application
import androidx.hilt.work.HiltWorkerFactory import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration import androidx.work.Configuration
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import com.shaarit.data.worker.LinkHealthCheckWorker import com.shaarit.data.worker.LinkHealthCheckWorker
@ -25,9 +27,17 @@ class ShaarItApp : Application(), Configuration.Provider {
} }
private fun setupHealthCheckWorker() { 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>( val healthCheckRequest = PeriodicWorkRequestBuilder<LinkHealthCheckWorker>(
12, TimeUnit.HOURS // Run twice a day 12, TimeUnit.HOURS // Toutes les 12h
).build() )
.setConstraints(constraints)
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork( WorkManager.getInstance(this).enqueueUniquePeriodicWork(
LinkHealthCheckWorker.WORK_NAME, LinkHealthCheckWorker.WORK_NAME,

View File

@ -1,10 +1,14 @@
package com.shaarit.core.di package com.shaarit.core.di
import android.content.Context
import androidx.work.WorkManager
import com.shaarit.core.storage.TokenManager import com.shaarit.core.storage.TokenManager
import com.shaarit.core.storage.TokenManagerImpl import com.shaarit.core.storage.TokenManagerImpl
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
@ -13,4 +17,12 @@ import javax.inject.Singleton
abstract class AppModule { abstract class AppModule {
@Binds @Singleton abstract fun bindTokenManager(impl: TokenManagerImpl): TokenManager @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.room.Update
import androidx.sqlite.db.SupportSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery
import com.shaarit.data.local.entity.ContentType 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.LinkEntity
import com.shaarit.data.local.entity.LinkFtsEntity import com.shaarit.data.local.entity.LinkFtsEntity
import com.shaarit.data.local.entity.SyncStatus import com.shaarit.data.local.entity.SyncStatus
@ -98,7 +99,7 @@ interface LinkDao {
@Query(""" @Query("""
SELECT * FROM links SELECT * FROM links
WHERE is_dead_link = 1 WHERE link_check_status = 'BROKEN'
ORDER BY last_health_check DESC ORDER BY last_health_check DESC
""") """)
fun getDeadLinks(): PagingSource<Int, LinkEntity> fun getDeadLinks(): PagingSource<Int, LinkEntity>
@ -106,14 +107,15 @@ interface LinkDao {
@Query(""" @Query("""
SELECT * FROM links SELECT * FROM links
WHERE url NOT LIKE 'note://%' WHERE url NOT LIKE 'note://%'
AND excluded_from_health_check = 0
AND (last_health_check < :timestamp OR last_health_check IS NULL) AND (last_health_check < :timestamp OR last_health_check IS NULL)
ORDER BY last_health_check ASC ORDER BY last_health_check ASC
LIMIT :limit LIMIT :limit
""") """)
suspend fun getLinksForHealthCheck(timestamp: Long, limit: Int): List<LinkEntity> suspend fun getLinksForHealthCheck(timestamp: Long, limit: Int): List<LinkEntity>
@Query("UPDATE links SET is_dead_link = :isDead, last_health_check = :timestamp WHERE id = :id") @Query("UPDATE links SET link_check_status = :status, fail_count = :failCount, last_health_check = :timestamp WHERE id = :id")
suspend fun updateLinkHealthStatus(id: Int, isDead: Boolean, timestamp: Long) suspend fun updateLinkHealthStatus(id: Int, status: LinkCheckStatus, failCount: Int, timestamp: Long)
// ====== Filtres temporels ====== // ====== Filtres temporels ======
@ -228,6 +230,35 @@ interface LinkDao {
@Query("SELECT * FROM links WHERE sync_status != 'PENDING_DELETE'") @Query("SELECT * FROM links WHERE sync_status != 'PENDING_DELETE'")
suspend fun getAllLinksForStats(): List<LinkEntity> 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( data class SiteCount(

View File

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

View File

@ -71,11 +71,17 @@ data class LinkEntity(
@ColumnInfo(name = "excerpt") @ColumnInfo(name = "excerpt")
val excerpt: String? = null, val excerpt: String? = null,
@ColumnInfo(name = "is_dead_link") @ColumnInfo(name = "link_check_status")
val isDeadLink: Boolean = false, val linkCheckStatus: LinkCheckStatus = LinkCheckStatus.VALID,
@ColumnInfo(name = "fail_count")
val failCount: Int = 0,
@ColumnInfo(name = "last_health_check") @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 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 * 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 com.shaarit.domain.repository.GeminiRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.json.JSONObject import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class GeminiRepositoryImpl @Inject constructor( class GeminiRepositoryImpl @Inject constructor(
private val tokenManager: TokenManager private val tokenManager: TokenManager,
private val okHttpClient: OkHttpClient
) : GeminiRepository { ) : GeminiRepository {
// Cache pour les tags // 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.")) 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)) { if (!isUrlAccessible(url)) {
return@withContext Result.failure(Exception("L'URL n'est pas accessible. Vérifiez qu'elle est valide.")) 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() 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 { 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 { return try {
val connection = URL(url).openConnection() as HttpURLConnection val request = Request.Builder()
connection.requestMethod = "HEAD" .url(url)
connection.connectTimeout = 5000 .head() // HEAD request to check headers only
connection.readTimeout = 5000 .header("User-Agent", userAgent)
connection.instanceFollowRedirects = true .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
val responseCode = connection.responseCode .header("Accept-Language", "en-US,en;q=0.9")
connection.disconnect() .build()
responseCode in 200..399
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) { } catch (e: Exception) {
// 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 false
} }
} }
}
private fun buildPrompt(url: String): String { 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 """ return """
Rôle: Tu es un assistant expert en classification de contenu web pour l'application Shaarli. 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 URL à analyser: $url
$specificInstructions
Règles CRITIQUES de sécurité anti-hallucination : 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. 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. 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, readingTime = readingTimeMinutes,
contentType = contentType.name, contentType = contentType.name,
siteName = siteName, 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? { private fun LinkDto.toEntity(): LinkEntity? {
val linkId = id ?: return null val linkId = id ?: return null
val linkUrl = url ?: return null val linkUrl = url ?: return null

View File

@ -413,7 +413,11 @@ class SyncManager @Inject constructor(
readingTimeMinutes = existing?.readingTimeMinutes, readingTimeMinutes = existing?.readingTimeMinutes,
contentType = existing?.contentType ?: com.shaarit.data.local.entity.ContentType.UNKNOWN, contentType = existing?.contentType ?: com.shaarit.data.local.entity.ContentType.UNKNOWN,
siteName = existing?.siteName, 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) { } catch (e: Exception) {
Log.w(TAG, "Lien ignoré (id=${dto.id}): ${e.message}") 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.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.shaarit.data.local.dao.LinkDao 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.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
@ -21,61 +27,166 @@ class LinkHealthCheckWorker @AssistedInject constructor(
companion object { companion object {
const val WORK_NAME = "link_health_check_work" const val WORK_NAME = "link_health_check_work"
private const val BATCH_SIZE = 20 private const val CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L // 12h
private const val CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000L // 24h private const val TIMEOUT_MS = 7000 // 7 secondes (entre 5-10s recommandé)
private const val TIMEOUT_MS = 10000 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) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
try { 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 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()) { if (linksToCheck.isEmpty()) {
return@withContext Result.success() return@withContext Result.success()
} }
var checkCount = 0 // Rate limiting avec Semaphore
val semaphore = Semaphore(MAX_CONCURRENT_CHECKS)
linksToCheck.forEach { link -> // Vérification en parallèle avec concurrence limitée
// Double vérification pour éviter les notes (déjà filtré par DAO normalement mais sécurité) linksToCheck.map { link ->
if (link.url.startsWith("note://")) { async {
return@forEach semaphore.withPermit {
checkLinkStatus(link)
} }
val isDead = !isUrlAccessible(link.url)
linkDao.updateLinkHealthStatus(link.id, isDead, System.currentTimeMillis())
checkCount++
} }
}.awaitAll()
// 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() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace()
Result.retry() 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 { 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 { return try {
val connection = URL(url).openConnection() as HttpURLConnection val url = URL(urlStr)
connection.requestMethod = "HEAD" connection = url.openConnection() as HttpURLConnection
connection.requestMethod = method
connection.connectTimeout = TIMEOUT_MS connection.connectTimeout = TIMEOUT_MS
connection.readTimeout = TIMEOUT_MS connection.readTimeout = TIMEOUT_MS
connection.instanceFollowRedirects = true connection.instanceFollowRedirects = true
// On accepte les codes 2xx et 3xx comme "vivants" // Headers pour simuler un vrai navigateur
// Certains sites bloquent HEAD, on pourrait fallback sur GET mais c'est lourd. connection.setRequestProperty("User-Agent", USER_AGENT)
// Pour l'instant on reste simple. connection.setRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
// Parfois 405 Method Not Allowed est retourné pour HEAD, ce qui veut dire que le serveur existe. connection.setRequestProperty("Accept-Language", "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7")
val responseCode = connection.responseCode connection.setRequestProperty("Accept-Encoding", "gzip, deflate, br")
connection.disconnect() connection.setRequestProperty("Connection", "keep-alive")
connection.setRequestProperty("Upgrade-Insecure-Requests", "1")
connection.connect()
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
responseCode in 200..399 || responseCode == 405
} catch (e: Exception) { } catch (e: Exception) {
// Timeout, DNS error, SSL error, etc. -> échec
false false
} finally {
connection?.disconnect()
} }
} }
} }

View File

@ -15,5 +15,85 @@ data class ShaarliLink(
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 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 = val result =
linkRepository.addOrUpdateLink( linkRepository.addOrUpdateLink(
url = if (_contentTypeSelection.value == ContentType.NOTE && currentUrl.isBlank()) url = finalUrl,
"note://local/${System.currentTimeMillis()}" else currentUrl, title = finalTitle.ifBlank { null },
title = title.value.ifBlank { null },
description = description.value.ifBlank { null }, description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null }, tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value, isPrivate = isPrivate.value,
@ -353,11 +364,22 @@ constructor(
_uiState.value = AddLinkUiState.Loading _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 = val result =
linkRepository.addOrUpdateLink( linkRepository.addOrUpdateLink(
url = if (_contentTypeSelection.value == ContentType.NOTE && currentUrl.isBlank()) url = finalUrl,
"note://local/${System.currentTimeMillis()}" else currentUrl, title = finalTitle.ifBlank { null },
title = title.value.ifBlank { null },
description = description.value.ifBlank { null }, description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null }, tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value, isPrivate = isPrivate.value,
@ -394,6 +416,13 @@ constructor(
removeTag("note") removeTag("note")
} }
} }
private fun generateRandomId(length: Int = 6): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
return (1..length)
.map { chars.random() }
.joinToString("")
}
} }
sealed class AddLinkUiState { sealed class AddLinkUiState {

View File

@ -2,13 +2,17 @@ package com.shaarit.presentation.deadlinks
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.BrokenImage import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.Delete 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -34,6 +38,8 @@ fun DeadLinksScreen(
val pagingItems = viewModel.pagedDeadLinks.collectAsLazyPagingItems() val pagingItems = viewModel.pagedDeadLinks.collectAsLazyPagingItems()
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val selectedLinkIds by viewModel.selectedLinkIds.collectAsState()
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
Box( Box(
modifier = Modifier modifier = Modifier
@ -50,21 +56,42 @@ fun DeadLinksScreen(
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
"Liens inaccessibles", if (isSelectionMode) "${selectedLinkIds.size} sélectionné(s)" else "Liens inaccessibles",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = TextPrimary color = TextPrimary
) )
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = {
if (isSelectionMode) {
viewModel.clearSelection()
} else {
onNavigateBack()
}
}) {
Icon( Icon(
Icons.Default.ArrowBack, if (isSelectionMode) Icons.Default.Close else Icons.Default.ArrowBack,
contentDescription = "Retour", contentDescription = if (isSelectionMode) "Annuler" else "Retour",
tint = TextPrimary 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( colors = TopAppBarDefaults.topAppBarColors(
containerColor = DeepNavy.copy(alpha = 0.9f), containerColor = DeepNavy.copy(alpha = 0.9f),
titleContentColor = TextPrimary titleContentColor = TextPrimary
@ -114,15 +141,23 @@ fun DeadLinksScreen(
val link = pagingItems[index] val link = pagingItems[index]
if (link != null) { if (link != null) {
// On utilise ListViewItem en lui passant un indicateur visuel // 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. // on va devoir le modifier ou wrapper l'item.
// Le viewmodel gère la suppression. // Le viewmodel gère la suppression.
ListViewItem( DeadLinkItem(
link = link, link = link,
isSelected = selectedLinkIds.contains(link.id),
onItemClick = { onItemClick = {
if (isSelectionMode) {
viewModel.toggleSelection(link.id)
} else {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url)) val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
context.startActivity(intent) context.startActivity(intent)
}
},
onLongClick = {
viewModel.toggleSelection(link.id)
}, },
onLinkClick = { url -> onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
@ -130,7 +165,6 @@ fun DeadLinksScreen(
}, },
onEditClick = onNavigateToEdit, onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) }, onDeleteClick = { viewModel.deleteLink(link.id) },
// Pas de tags clickables ici pour simplifier
onTagClick = { }, onTagClick = { },
onViewClick = { } 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.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.domain.model.ShaarliLink import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.repository.LinkRepository import com.shaarit.domain.repository.LinkRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow 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 kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class DeadLinksViewModel @Inject constructor( class DeadLinksViewModel @Inject constructor(
private val linkRepository: LinkRepository private val linkRepository: LinkRepository,
private val linkDao: LinkDao
) : ViewModel() { ) : ViewModel() {
val pagedDeadLinks: Flow<PagingData<ShaarliLink>> = val pagedDeadLinks: Flow<PagingData<ShaarliLink>> =
linkRepository.getDeadLinksStream() linkRepository.getDeadLinksStream()
.cachedIn(viewModelScope) .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) { fun deleteLink(id: Int) {
viewModelScope.launch { viewModelScope.launch {
linkRepository.deleteLink(id) 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( linkRepository.updateLink(
id = linkId, id = linkId,
url = if (_contentType.value == ContentType.NOTE && currentUrl.isBlank()) url = finalUrl,
"note://local/${System.currentTimeMillis()}" else currentUrl, title = finalTitle.ifBlank { null },
title = currentTitle.ifBlank { null },
description = description.value.ifBlank { null }, description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null }, tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value 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 { 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.PremiumTextField
import com.shaarit.ui.components.TagChip import com.shaarit.ui.components.TagChip
import com.shaarit.ui.theme.* 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 import kotlinx.coroutines.launch
// ============== Accordion Section Component ============== // ============== Accordion Section Component ==============
@ -288,6 +292,7 @@ fun FeedScreen(
val tags by viewModel.tags.collectAsState() val tags by viewModel.tags.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val haptic = LocalHapticFeedback.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
var showViewStyleMenu by remember { mutableStateOf(false) } var showViewStyleMenu by remember { mutableStateOf(false) }
@ -312,6 +317,7 @@ fun FeedScreen(
val pullRefreshState = rememberPullRefreshState( val pullRefreshState = rememberPullRefreshState(
refreshing = pagingItems.loadState.refresh is LoadState.Loading, refreshing = pagingItems.loadState.refresh is LoadState.Loading,
onRefresh = { onRefresh = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
viewModel.refresh() viewModel.refresh()
pagingItems.refresh() pagingItems.refresh()
} }
@ -784,6 +790,7 @@ fun FeedScreen(
// Refresh Button // Refresh Button
IconButton(onClick = { IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
viewModel.refresh() viewModel.refresh()
pagingItems.refresh() pagingItems.refresh()
}) { }) {
@ -1332,10 +1339,7 @@ fun FeedScreen(
when { when {
pagingItems.loadState.refresh is LoadState.Loading && pagingItems.itemCount == 0 -> { pagingItems.loadState.refresh is LoadState.Loading && pagingItems.itemCount == 0 -> {
// Initial loading // Initial loading
Box( com.shaarit.ui.components.SkeletonLinkList(modifier = Modifier.fillMaxSize())
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) { CircularProgressIndicator(color = CyanPrimary) }
} }
pagingItems.loadState.refresh is LoadState.Error && pagingItems.itemCount == 0 -> { pagingItems.loadState.refresh is LoadState.Error && pagingItems.itemCount == 0 -> {
Box( Box(
@ -1412,6 +1416,7 @@ fun FeedScreen(
} }
}, },
onItemLongClick = { onItemLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (!selectionMode) { if (!selectionMode) {
selectionMode = true selectionMode = true
selectedIds = setOf(link.id) selectedIds = setOf(link.id)
@ -1471,6 +1476,7 @@ fun FeedScreen(
} }
}, },
onItemLongClick = { onItemLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (!selectionMode) { if (!selectionMode) {
selectionMode = true selectionMode = true
selectedIds = setOf(link.id) selectedIds = setOf(link.id)
@ -1530,6 +1536,7 @@ fun FeedScreen(
} }
}, },
onItemLongClick = { onItemLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (!selectionMode) { if (!selectionMode) {
selectionMode = true selectionMode = true
selectedIds = setOf(link.id) selectedIds = setOf(link.id)
@ -1582,11 +1589,17 @@ fun FeedScreen(
) )
} }
} }
}
if (selectedLink != null) { // Link Details Overlay (Hero-like transition)
LinkDetailsDialog( AnimatedVisibility(
link = selectedLink!!, 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 }, onDismiss = { selectedLink = null },
onLinkClick = { url -> onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
@ -1594,6 +1607,7 @@ fun FeedScreen(
} }
) )
} }
}
if (showAddToCollectionDialog) { if (showAddToCollectionDialog) {
val regularCollections = remember(collections) { collections.filter { !it.isSmart } } val regularCollections = remember(collections) { collections.filter { !it.isSmart } }
@ -1634,4 +1648,5 @@ 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.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.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.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.window.DialogProperties 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.shaarit.domain.model.HealthStatus
import com.shaarit.domain.model.ShaarliLink import com.shaarit.domain.model.ShaarliLink
import com.shaarit.ui.components.GlassCard import com.shaarit.ui.components.GlassCard
import com.shaarit.ui.components.TagChip import com.shaarit.ui.components.TagChip
import com.shaarit.ui.theme.* import com.shaarit.ui.theme.*
import dev.jeziellago.compose.markdowntext.MarkdownText 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 * Full list view item - shows all details including markdown description
@ -58,7 +65,7 @@ fun ListViewItem(
if (showDeleteDialog) { if (showDeleteDialog) {
DeleteConfirmationDialog( DeleteConfirmationDialog(
linkTitle = link.title, linkTitle = link.displayTitle,
onConfirm = { onConfirm = {
onDeleteClick() onDeleteClick()
showDeleteDialog = false showDeleteDialog = false
@ -76,12 +83,24 @@ fun ListViewItem(
Column { Column {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.Top 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)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = link.title, text = link.displayTitle,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = CyanPrimary, color = CyanPrimary,
@ -91,18 +110,18 @@ fun ListViewItem(
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (link.isDeadLink) { HealthStatusIcon(
Icon( healthStatus = link.healthStatus,
imageVector = Icons.Default.BrokenImage,
contentDescription = "Lien mort",
tint = ErrorRed,
modifier = Modifier.size(16.dp).padding(end = 4.dp) 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 = if (link.isDeadLink) ErrorRed else TealSecondary, color = when (link.healthStatus) {
HealthStatus.DEAD -> ErrorRed
HealthStatus.OK -> TealSecondary
else -> TextMuted
},
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@ -230,7 +249,7 @@ fun GridViewItem(
if (showDeleteDialog) { if (showDeleteDialog) {
DeleteConfirmationDialog( DeleteConfirmationDialog(
linkTitle = link.title, linkTitle = link.displayTitle,
onConfirm = { onConfirm = {
onDeleteClick() onDeleteClick()
showDeleteDialog = false showDeleteDialog = false
@ -242,7 +261,7 @@ fun GridViewItem(
GlassCard( GlassCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp), .heightIn(min = 220.dp),
onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() }, onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() },
onLongClick = onItemLongClick, onLongClick = onItemLongClick,
glowColor = if (isSelected) CyanPrimary else CyanPrimary glowColor = if (isSelected) CyanPrimary else CyanPrimary
@ -252,26 +271,40 @@ fun GridViewItem(
verticalArrangement = Arrangement.SpaceBetween verticalArrangement = Arrangement.SpaceBetween
) { ) {
Column { 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 // Title with pin indicator
Row( Row(
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
if (link.isDeadLink) { HealthStatusIcon(
Icon( healthStatus = link.healthStatus,
imageVector = Icons.Default.BrokenImage,
contentDescription = "Lien mort",
tint = ErrorRed,
modifier = Modifier.size(16.dp).padding(end = 4.dp).align(Alignment.CenterVertically) modifier = Modifier.size(16.dp).padding(end = 4.dp).align(Alignment.CenterVertically)
) )
}
Text( Text(
text = link.title, text = link.displayTitle,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, 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, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@ -431,7 +464,7 @@ fun CompactViewItem(
if (showDeleteDialog) { if (showDeleteDialog) {
DeleteConfirmationDialog( DeleteConfirmationDialog(
linkTitle = link.title, linkTitle = link.displayTitle,
onConfirm = { onConfirm = {
onDeleteClick() onDeleteClick()
showDeleteDialog = false showDeleteDialog = false
@ -489,19 +522,19 @@ fun CompactViewItem(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (link.isDeadLink) { HealthStatusIcon(
Icon( healthStatus = link.healthStatus,
imageVector = Icons.Default.BrokenImage,
contentDescription = "Lien mort",
tint = ErrorRed,
modifier = Modifier.size(14.dp).padding(end = 4.dp) modifier = Modifier.size(14.dp).padding(end = 4.dp)
) )
}
Text( Text(
text = link.title, text = link.displayTitle,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium, 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, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@ -615,21 +648,33 @@ fun DeleteConfirmationDialog(
/** /**
* Dialog to show full link details * 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) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun LinkDetailsDialog( fun LinkDetailsView(
link: ShaarliLink, link: ShaarliLink,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onLinkClick: (String) -> Unit onLinkClick: (String) -> Unit
) { ) {
androidx.compose.ui.window.Dialog( Box(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
GlassCard(
modifier = Modifier modifier = Modifier
.fillMaxSize() .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
.padding(16.dp) .padding(16.dp)
.fillMaxWidth()
.fillMaxHeight(0.9f)
.clickable(enabled = false, onClick = {})
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -643,7 +688,7 @@ fun LinkDetailsDialog(
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
Text( Text(
text = link.title, text = link.displayTitle,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = CyanPrimary, color = CyanPrimary,
@ -665,6 +710,20 @@ fun LinkDetailsDialog(
Spacer(modifier = Modifier.height(16.dp)) 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 // Scrollable Content
Column( Column(
modifier = Modifier 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") { navArgument("collectionId") {
type = NavType.LongType type = NavType.LongType
nullable = false
defaultValue = -1L defaultValue = -1L
} }
), ),

View File

@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.LinkOff
import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -226,6 +227,29 @@ fun SettingsScreen(
) )
} }
// 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 // About Section
item { item {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@ -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.local.dao.LinkDao
import com.shaarit.data.sync.SyncManager import com.shaarit.data.sync.SyncManager
import com.shaarit.data.sync.SyncState import com.shaarit.data.sync.SyncState
import com.shaarit.data.worker.LinkHealthCheckWorker
import com.shaarit.domain.usecase.ClassifyBookmarksUseCase import com.shaarit.domain.usecase.ClassifyBookmarksUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@ -16,6 +17,9 @@ import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
import androidx.work.WorkManager
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor( class SettingsViewModel @Inject constructor(
@ -24,7 +28,8 @@ class SettingsViewModel @Inject constructor(
private val syncManager: SyncManager, private val syncManager: SyncManager,
private val linkDao: LinkDao, private val linkDao: LinkDao,
private val classifyBookmarksUseCase: ClassifyBookmarksUseCase, private val classifyBookmarksUseCase: ClassifyBookmarksUseCase,
private val tokenManager: TokenManager private val tokenManager: TokenManager,
private val workManager: WorkManager
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState()) private val _uiState = MutableStateFlow(SettingsUiState())
@ -36,8 +41,28 @@ class SettingsViewModel @Inject constructor(
private val _geminiApiKey = MutableStateFlow(tokenManager.getGeminiApiKey() ?: "") private val _geminiApiKey = MutableStateFlow(tokenManager.getGeminiApiKey() ?: "")
val geminiApiKey: StateFlow<String> = _geminiApiKey.asStateFlow() 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 { init {
observeSyncStatus() observeSyncStatus()
observeHealthCheckWork()
} }
private fun observeSyncStatus() { 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) { fun exportToJson(uri: Uri) {
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true) _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.