chore: Remove outdated documentation files (analysis and build instructions)
This commit is contained in:
parent
02c7300c3b
commit
80ab3009aa
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -28,7 +28,7 @@ import com.shaarit.data.local.entity.TagEntity
|
||||
CollectionEntity::class,
|
||||
CollectionLinkCrossRef::class
|
||||
],
|
||||
version = 1,
|
||||
version = 4,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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) {
|
||||
// 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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
// Rate limiting avec Semaphore
|
||||
val semaphore = Semaphore(MAX_CONCURRENT_CHECKS)
|
||||
|
||||
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
|
||||
// Vérification en parallèle avec concurrence limitée
|
||||
linksToCheck.map { link ->
|
||||
async {
|
||||
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()
|
||||
} 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()
|
||||
|
||||
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) {
|
||||
// Timeout, DNS error, SSL error, etc. -> échec
|
||||
false
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 = {
|
||||
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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
@ -1582,11 +1589,17 @@ fun FeedScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedLink != null) {
|
||||
LinkDetailsDialog(
|
||||
link = selectedLink!!,
|
||||
// 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))
|
||||
@ -1594,6 +1607,7 @@ fun FeedScreen(
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddToCollectionDialog) {
|
||||
val regularCollections = remember(collections) { collections.filter { !it.isSmart } }
|
||||
@ -1635,3 +1649,4 @@ fun FeedScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
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,
|
||||
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,
|
||||
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)
|
||||
) {
|
||||
GlassCard(
|
||||
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
|
||||
.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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,6 +104,7 @@ fun AppNavGraph(
|
||||
},
|
||||
navArgument("collectionId") {
|
||||
type = NavType.LongType
|
||||
nullable = false
|
||||
defaultValue = -1L
|
||||
}
|
||||
),
|
||||
|
||||
@ -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.*
|
||||
@ -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
|
||||
item {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
171
app/src/main/java/com/shaarit/ui/components/SkeletonLoader.kt
Normal file
171
app/src/main/java/com/shaarit/ui/components/SkeletonLoader.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
94
docs/RAPPORT_AMELIORATION_v2.md
Normal file
94
docs/RAPPORT_AMELIORATION_v2.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user