From 80ab3009aab59bfad5dd0969bbce8bca9bc7b36c Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 9 Feb 2026 09:48:25 -0500 Subject: [PATCH] chore: Remove outdated documentation files (analysis and build instructions) --- app/src/main/java/com/shaarit/ShaarItApp.kt | 14 +- .../java/com/shaarit/core/di/AppModule.kt | 12 ++ .../com/shaarit/data/local/dao/LinkDao.kt | 37 +++- .../data/local/database/ShaarliDatabase.kt | 2 +- .../shaarit/data/local/entity/LinkEntity.kt | 21 +- .../data/repository/GeminiRepositoryImpl.kt | 84 ++++++-- .../data/repository/LinkRepositoryImpl.kt | 12 +- .../java/com/shaarit/data/sync/SyncManager.kt | 6 +- .../data/worker/LinkHealthCheckWorker.kt | 167 ++++++++++++--- .../java/com/shaarit/domain/model/Models.kt | 84 +++++++- .../presentation/add/AddLinkViewModel.kt | 41 +++- .../presentation/deadlinks/DeadLinksScreen.kt | 88 +++++++- .../deadlinks/DeadLinksViewModel.kt | 42 +++- .../presentation/edit/EditLinkViewModel.kt | 24 ++- .../shaarit/presentation/feed/FeedScreen.kt | 45 ++-- .../presentation/feed/LinkItemViews.kt | 192 ++++++++++++++---- .../com/shaarit/presentation/nav/NavGraph.kt | 1 + .../presentation/settings/SettingsScreen.kt | 128 ++++++++++++ .../settings/SettingsViewModel.kt | 54 ++++- .../shaarit/ui/components/SkeletonLoader.kt | 171 ++++++++++++++++ build_output.txt | 67 ------ compile_output.txt | 44 ---- .../ANALYSE_ET_AMELIORATIONS.md | 0 docs/RAPPORT_AMELIORATION_v2.md | 94 +++++++++ RELEASE_BUILD.md => docs/RELEASE_BUILD.md | 0 25 files changed, 1189 insertions(+), 241 deletions(-) create mode 100644 app/src/main/java/com/shaarit/ui/components/SkeletonLoader.kt delete mode 100644 build_output.txt delete mode 100644 compile_output.txt rename ANALYSE_ET_AMELIORATIONS.md => docs/ANALYSE_ET_AMELIORATIONS.md (100%) create mode 100644 docs/RAPPORT_AMELIORATION_v2.md rename RELEASE_BUILD.md => docs/RELEASE_BUILD.md (100%) diff --git a/app/src/main/java/com/shaarit/ShaarItApp.kt b/app/src/main/java/com/shaarit/ShaarItApp.kt index e7b728e..4de3384 100644 --- a/app/src/main/java/com/shaarit/ShaarItApp.kt +++ b/app/src/main/java/com/shaarit/ShaarItApp.kt @@ -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( - 12, TimeUnit.HOURS // Run twice a day - ).build() + 12, TimeUnit.HOURS // Toutes les 12h + ) + .setConstraints(constraints) + .build() WorkManager.getInstance(this).enqueueUniquePeriodicWork( LinkHealthCheckWorker.WORK_NAME, diff --git a/app/src/main/java/com/shaarit/core/di/AppModule.kt b/app/src/main/java/com/shaarit/core/di/AppModule.kt index 11ef47a..be5f764 100644 --- a/app/src/main/java/com/shaarit/core/di/AppModule.kt +++ b/app/src/main/java/com/shaarit/core/di/AppModule.kt @@ -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) + } + } } diff --git a/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt b/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt index cf7c879..838893e 100644 --- a/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt +++ b/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt @@ -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 @@ -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 - @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 + + // ====== Health Check Statistics ====== + + @Query("SELECT COUNT(*) FROM links WHERE url NOT LIKE 'note://%'") + fun getTotalBookmarksCount(): Flow + + @Query("SELECT COUNT(*) FROM links WHERE url NOT LIKE 'note://%' AND last_health_check > 0") + fun getTestedBookmarksCount(): Flow + + @Query("SELECT COUNT(*) FROM links WHERE link_check_status = 'BROKEN'") + fun getDeadLinksCount(): Flow + + @Query("SELECT COUNT(*) FROM links WHERE link_check_status = 'PENDING'") + fun getPendingLinksCount(): Flow + + @Query("SELECT MAX(last_health_check) FROM links WHERE last_health_check > 0") + fun getLastHealthCheckTime(): Flow + + @Query("SELECT * FROM links WHERE link_check_status = 'BROKEN' ORDER BY last_health_check DESC") + fun getDeadLinksFlow(): Flow> + + @Query("SELECT * FROM links WHERE link_check_status = 'PENDING' ORDER BY fail_count DESC, last_health_check DESC") + fun getPendingLinksFlow(): Flow> + + @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, excluded: Boolean) } data class SiteCount( diff --git a/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt b/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt index 9e8b1a2..55db148 100644 --- a/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt +++ b/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt @@ -28,7 +28,7 @@ import com.shaarit.data.local.entity.TagEntity CollectionEntity::class, CollectionLinkCrossRef::class ], - version = 1, + version = 4, exportSchema = false ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt b/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt index 0476baf..0c181f5 100644 --- a/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt +++ b/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt @@ -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 */ diff --git a/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt index c131f25..1f6a821 100644 --- a/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt +++ b/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt @@ -9,15 +9,17 @@ import com.shaarit.domain.model.AiEnrichmentResult import com.shaarit.domain.repository.GeminiRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response import org.json.JSONObject -import java.net.HttpURLConnection -import java.net.URL import javax.inject.Inject import javax.inject.Singleton @Singleton class GeminiRepositoryImpl @Inject constructor( - private val tokenManager: TokenManager + private val tokenManager: TokenManager, + private val okHttpClient: OkHttpClient ) : GeminiRepository { // Cache pour les tags @@ -122,7 +124,7 @@ class GeminiRepositoryImpl @Inject constructor( return@withContext Result.failure(Exception("Clé API Gemini non configurée. Allez dans Paramètres pour la configurer.")) } - // Verify URL is accessible + // Verify URL is accessible using OkHttp if (!isUrlAccessible(url)) { return@withContext Result.failure(Exception("L'URL n'est pas accessible. Vérifiez qu'elle est valide.")) } @@ -210,22 +212,76 @@ class GeminiRepositoryImpl @Inject constructor( return parseGeminiResponse(responseText).getOrThrow() } + // Client dédié pour la vérification des liens (sans les intercepteurs de l'API Shaarli) + private val linkVerifierClient by lazy { + val builder = okHttpClient.newBuilder() + builder.interceptors().clear() // On retire les intercepteurs (Auth, HostSelection) + builder + .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(15, java.util.concurrent.TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .retryOnConnectionFailure(true) + .build() + } + private fun isUrlAccessible(url: String): Boolean { + // User-Agent "Standard" pour éviter les blocages anti-bot + val userAgent = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" + return try { - val connection = URL(url).openConnection() as HttpURLConnection - connection.requestMethod = "HEAD" - connection.connectTimeout = 5000 - connection.readTimeout = 5000 - connection.instanceFollowRedirects = true - val responseCode = connection.responseCode - connection.disconnect() - responseCode in 200..399 + val request = Request.Builder() + .url(url) + .head() // HEAD request to check headers only + .header("User-Agent", userAgent) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + .header("Accept-Language", "en-US,en;q=0.9") + .build() + + linkVerifierClient.newCall(request).execute().use { response: Response -> + // Considérer comme un succès si le serveur répond, même avec une erreur + // Seules 404 (Not Found) et 410 (Gone) signifient vraiment que le lien est mort. + // 403 (Forbidden) = le lien existe mais on est bloqué (donc Vivant). + // 200..299 = Vivant. + response.code != 404 && response.code != 410 + } } catch (e: Exception) { - false + // Fallback: try GET if HEAD fails (some servers block HEAD or unexpected error) + try { + val request = Request.Builder() + .url(url) + .get() + .header("User-Agent", userAgent) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + .build() + + linkVerifierClient.newCall(request).execute().use { response: Response -> + response.code != 404 && response.code != 410 + } + } catch (e2: Exception) { + // Échec de connexion (DNS, Timeout, SSL Handshake fatal) -> Lien mort + false + } } } private fun buildPrompt(url: String): String { + val specificInstructions = when { + url.contains("youtube.com") || url.contains("youtu.be") -> """ + - C'est une vidéo YouTube. Essaie d'identifier le créateur de la vidéo. + - Si la description de la vidéo est disponible, utilise-la pour le résumé. + - Tags suggérés: youtube, video, [nom de la chaîne], [sujet principal] + """.trimIndent() + + url.contains("github.com") || url.contains("gitlab.com") -> """ + - C'est un dépôt de code. Essaie d'identifier le langage principal. + - Extrait l'objectif du projet depuis le README si visible. + - Tags suggérés: dev, open-source, [langage], [framework] + """.trimIndent() + + else -> "" + } + return """ Rôle: Tu es un assistant expert en classification de contenu web pour l'application Shaarli. @@ -233,6 +289,8 @@ Tâche: Analyser les métadonnées de l'URL fournie pour extraire un titre, une URL à analyser: $url +$specificInstructions + Règles CRITIQUES de sécurité anti-hallucination : 1. Si tu ne peux pas accéder au contenu réel de la page ou si l'URL est opaque (ex: raccourci, ID vidéo), NE DEVINE PAS. Base-toi uniquement sur les informations explicites dans l'URL. 2. Si c'est une vidéo YouTube, essaie d'extraire le contexte de l'ID si tu le connais, sinon indique "Vérifier le titre" dans le titre. diff --git a/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt index 09c65f2..d50e302 100644 --- a/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt +++ b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt @@ -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 diff --git a/app/src/main/java/com/shaarit/data/sync/SyncManager.kt b/app/src/main/java/com/shaarit/data/sync/SyncManager.kt index 5abe239..974e879 100644 --- a/app/src/main/java/com/shaarit/data/sync/SyncManager.kt +++ b/app/src/main/java/com/shaarit/data/sync/SyncManager.kt @@ -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}") diff --git a/app/src/main/java/com/shaarit/data/worker/LinkHealthCheckWorker.kt b/app/src/main/java/com/shaarit/data/worker/LinkHealthCheckWorker.kt index 270b7c5..7d06ce1 100644 --- a/app/src/main/java/com/shaarit/data/worker/LinkHealthCheckWorker.kt +++ b/app/src/main/java/com/shaarit/data/worker/LinkHealthCheckWorker.kt @@ -5,10 +5,16 @@ import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.shaarit.data.local.dao.LinkDao +import com.shaarit.data.local.entity.LinkCheckStatus +import com.shaarit.data.local.entity.LinkEntity import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import java.net.HttpURLConnection import java.net.URL @@ -21,61 +27,166 @@ class LinkHealthCheckWorker @AssistedInject constructor( companion object { const val WORK_NAME = "link_health_check_work" - private const val BATCH_SIZE = 20 - private const val CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000L // 24h - private const val TIMEOUT_MS = 10000 + private const val CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L // 12h + private const val TIMEOUT_MS = 7000 // 7 secondes (entre 5-10s recommandé) + private const val MAX_CONCURRENT_CHECKS = 10 // Limité pour éviter le rate limiting + private const val FAILURE_THRESHOLD = 3 // Nombre d'échecs avant de marquer comme BROKEN + + // User-Agent moderne (Chrome sur Android) + private const val USER_AGENT = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" + + // Patterns d'exclusion (whitelist) + private val EXCLUDED_URL_PREFIXES = listOf( + "note://", + "http://shaare", + "/shaare", + "file://", + "localhost", + "127.0.0.1", + "192.168.", + "10.0.", + "172.16." + ) + + private val EXCLUDED_TAGS = setOf( + "note", + "#note", + "local-network", + "#local-network" + ) } override suspend fun doWork(): Result = withContext(Dispatchers.IO) { try { - // Récupérer les liens qui n'ont pas été vérifiés récemment - // On vérifie ceux qui ont été checkés il y a plus de 24h (ou jamais) val threshold = System.currentTimeMillis() - CHECK_INTERVAL_MS - val linksToCheck = linkDao.getLinksForHealthCheck(threshold, BATCH_SIZE) + val allLinks = linkDao.getLinksForHealthCheck(threshold, Int.MAX_VALUE) + + if (allLinks.isEmpty()) { + return@withContext Result.success() + } + + // Filtrage avec whitelist + val linksToCheck = allLinks.filter { link -> shouldCheckLink(link) } if (linksToCheck.isEmpty()) { return@withContext Result.success() } - var checkCount = 0 - - linksToCheck.forEach { link -> - // Double vérification pour éviter les notes (déjà filtré par DAO normalement mais sécurité) - if (link.url.startsWith("note://")) { - return@forEach + // Rate limiting avec Semaphore + val semaphore = Semaphore(MAX_CONCURRENT_CHECKS) + + // Vérification en parallèle avec concurrence limitée + linksToCheck.map { link -> + async { + semaphore.withPermit { + checkLinkStatus(link) + } } + }.awaitAll() - val isDead = !isUrlAccessible(link.url) - linkDao.updateLinkHealthStatus(link.id, isDead, System.currentTimeMillis()) - checkCount++ - } - - // S'il reste des liens à vérifier, on renvoie retry ou success pour que le prochain run (périodique) s'en charge - // Comme c'est un Worker périodique, Success suffit, il sera relancé plus tard par le scheduler Result.success() } catch (e: Exception) { + e.printStackTrace() Result.retry() } } + private fun shouldCheckLink(link: LinkEntity): Boolean { + // Exclure par URL + val urlLower = link.url.lowercase() + if (EXCLUDED_URL_PREFIXES.any { urlLower.startsWith(it) }) { + return false + } + + // Exclure par tags + if (link.tags.any { tag -> EXCLUDED_TAGS.contains(tag.lowercase()) }) { + return false + } + + return true + } + + private suspend fun checkLinkStatus(link: LinkEntity) { + val isAccessible = isUrlAccessible(link.url) + val timestamp = System.currentTimeMillis() + + if (isAccessible) { + // Succès: reset fail_count et marquer comme VALID + linkDao.updateLinkHealthStatus( + id = link.id, + status = LinkCheckStatus.VALID, + failCount = 0, + timestamp = timestamp + ) + } else { + // Échec: incrémenter fail_count et déterminer le statut + handleFailure(link, timestamp) + } + } + + private suspend fun handleFailure(link: LinkEntity, timestamp: Long) { + val newFailCount = link.failCount + 1 + val newStatus = if (newFailCount >= FAILURE_THRESHOLD) { + LinkCheckStatus.BROKEN + } else { + LinkCheckStatus.PENDING + } + + linkDao.updateLinkHealthStatus( + id = link.id, + status = newStatus, + failCount = newFailCount, + timestamp = timestamp + ) + } + private fun isUrlAccessible(url: String): Boolean { + // Premier essai avec HEAD (économise bande passante) + if (checkUrl(url, "HEAD")) return true + + // Fallback avec GET si HEAD échoue (certains serveurs bloquent HEAD) + return checkUrl(url, "GET") + } + + private fun checkUrl(urlStr: String, method: String): Boolean { + var connection: HttpURLConnection? = null return try { - val connection = URL(url).openConnection() as HttpURLConnection - connection.requestMethod = "HEAD" + val url = URL(urlStr) + connection = url.openConnection() as HttpURLConnection + connection.requestMethod = method connection.connectTimeout = TIMEOUT_MS connection.readTimeout = TIMEOUT_MS connection.instanceFollowRedirects = true - // On accepte les codes 2xx et 3xx comme "vivants" - // Certains sites bloquent HEAD, on pourrait fallback sur GET mais c'est lourd. - // Pour l'instant on reste simple. - // Parfois 405 Method Not Allowed est retourné pour HEAD, ce qui veut dire que le serveur existe. - val responseCode = connection.responseCode - connection.disconnect() + // Headers pour simuler un vrai navigateur + connection.setRequestProperty("User-Agent", USER_AGENT) + connection.setRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8") + connection.setRequestProperty("Accept-Language", "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7") + connection.setRequestProperty("Accept-Encoding", "gzip, deflate, br") + connection.setRequestProperty("Connection", "keep-alive") + connection.setRequestProperty("Upgrade-Insecure-Requests", "1") + + connection.connect() - responseCode in 200..399 || responseCode == 405 + val responseCode = connection.responseCode + + // Codes considérés comme "valides" (le serveur répond) + // 2xx: Succès + // 3xx: Redirection + // 401/403: Protégé mais existe + // 405: Méthode non autorisée mais existe + // 429: Rate limited mais existe + responseCode in 200..399 || + responseCode == 401 || + responseCode == 403 || + responseCode == 405 || + responseCode == 429 + } catch (e: Exception) { + // Timeout, DNS error, SSL error, etc. -> échec false + } finally { + connection?.disconnect() } } } diff --git a/app/src/main/java/com/shaarit/domain/model/Models.kt b/app/src/main/java/com/shaarit/domain/model/Models.kt index dd2358b..177fa72 100644 --- a/app/src/main/java/com/shaarit/domain/model/Models.kt +++ b/app/src/main/java/com/shaarit/domain/model/Models.kt @@ -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 +} diff --git a/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt b/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt index 3579ec4..53c2a48 100644 --- a/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt @@ -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 { diff --git a/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksScreen.kt b/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksScreen.kt index 3dcae3d..6bca54c 100644 --- a/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksScreen.kt @@ -2,13 +2,17 @@ package com.shaarit.presentation.deadlinks import android.content.Intent import android.net.Uri +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.BrokenImage import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -34,6 +38,8 @@ fun DeadLinksScreen( val pagingItems = viewModel.pagedDeadLinks.collectAsLazyPagingItems() val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } + val selectedLinkIds by viewModel.selectedLinkIds.collectAsState() + val isSelectionMode by viewModel.isSelectionMode.collectAsState() Box( modifier = Modifier @@ -50,21 +56,42 @@ fun DeadLinksScreen( TopAppBar( title = { Text( - "Liens inaccessibles", + if (isSelectionMode) "${selectedLinkIds.size} sélectionné(s)" else "Liens inaccessibles", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = TextPrimary ) }, navigationIcon = { - IconButton(onClick = onNavigateBack) { + IconButton(onClick = { + if (isSelectionMode) { + viewModel.clearSelection() + } else { + onNavigateBack() + } + }) { Icon( - Icons.Default.ArrowBack, - contentDescription = "Retour", + if (isSelectionMode) Icons.Default.Close else Icons.Default.ArrowBack, + contentDescription = if (isSelectionMode) "Annuler" else "Retour", tint = TextPrimary ) } }, + actions = { + if (isSelectionMode) { + IconButton( + onClick = { + viewModel.excludeSelectedFromHealthCheck() + } + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Exclure de la vérification", + tint = SuccessGreen + ) + } + } + }, colors = TopAppBarDefaults.topAppBarColors( containerColor = DeepNavy.copy(alpha = 0.9f), titleContentColor = TextPrimary @@ -114,15 +141,23 @@ fun DeadLinksScreen( val link = pagingItems[index] if (link != null) { // On utilise ListViewItem en lui passant un indicateur visuel - // Note: Pour l'instant ListViewItem ne gère pas isDeadLink visuellement, + // Note: Pour l'instant ListViewItem ne gère pas linkCheckStatus visuellement, // on va devoir le modifier ou wrapper l'item. // Le viewmodel gère la suppression. - ListViewItem( + DeadLinkItem( link = link, + isSelected = selectedLinkIds.contains(link.id), onItemClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url)) - context.startActivity(intent) + if (isSelectionMode) { + viewModel.toggleSelection(link.id) + } else { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url)) + context.startActivity(intent) + } + }, + onLongClick = { + viewModel.toggleSelection(link.id) }, onLinkClick = { url -> val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) @@ -130,7 +165,6 @@ fun DeadLinksScreen( }, onEditClick = onNavigateToEdit, onDeleteClick = { viewModel.deleteLink(link.id) }, - // Pas de tags clickables ici pour simplifier onTagClick = { }, onViewClick = { } ) @@ -142,3 +176,39 @@ fun DeadLinksScreen( } } } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun DeadLinkItem( + link: com.shaarit.domain.model.ShaarliLink, + isSelected: Boolean, + onItemClick: () -> Unit, + onLongClick: () -> Unit, + onLinkClick: (String) -> Unit, + onEditClick: (Int) -> Unit, + onDeleteClick: (Int) -> Unit, + onTagClick: (String) -> Unit, + onViewClick: (Int) -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = onItemClick, + onLongClick = onLongClick + ), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) CyanPrimary.copy(alpha = 0.1f) else CardBackground + ) + ) { + ListViewItem( + link = link, + onItemClick = onItemClick, + onLinkClick = onLinkClick, + onEditClick = { onEditClick(link.id) }, + onDeleteClick = { onDeleteClick(link.id) }, + onTagClick = onTagClick, + onViewClick = { onViewClick(link.id) } + ) + } +} diff --git a/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksViewModel.kt b/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksViewModel.kt index 8269e2b..d2b6513 100644 --- a/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksViewModel.kt @@ -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> = linkRepository.getDeadLinksStream() .cachedIn(viewModelScope) + private val _selectedLinkIds = MutableStateFlow>(emptySet()) + val selectedLinkIds: StateFlow> = _selectedLinkIds.asStateFlow() + + private val _isSelectionMode = MutableStateFlow(false) + val isSelectionMode: StateFlow = _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) diff --git a/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt b/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt index 5fc7abc..1fdccc7 100644 --- a/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt @@ -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 { diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt index 86a4575..8d019e8 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt @@ -44,6 +44,10 @@ import com.shaarit.domain.model.ViewStyle import com.shaarit.ui.components.PremiumTextField import com.shaarit.ui.components.TagChip import com.shaarit.ui.theme.* +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.animation.* +import androidx.compose.animation.core.tween import kotlinx.coroutines.launch // ============== Accordion Section Component ============== @@ -288,6 +292,7 @@ fun FeedScreen( val tags by viewModel.tags.collectAsState() val context = LocalContext.current + val haptic = LocalHapticFeedback.current val scope = rememberCoroutineScope() val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) var showViewStyleMenu by remember { mutableStateOf(false) } @@ -312,6 +317,7 @@ fun FeedScreen( val pullRefreshState = rememberPullRefreshState( refreshing = pagingItems.loadState.refresh is LoadState.Loading, onRefresh = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) viewModel.refresh() pagingItems.refresh() } @@ -784,6 +790,7 @@ fun FeedScreen( // Refresh Button IconButton(onClick = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) viewModel.refresh() pagingItems.refresh() }) { @@ -1332,10 +1339,7 @@ fun FeedScreen( when { pagingItems.loadState.refresh is LoadState.Loading && pagingItems.itemCount == 0 -> { // Initial loading - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { CircularProgressIndicator(color = CyanPrimary) } + com.shaarit.ui.components.SkeletonLinkList(modifier = Modifier.fillMaxSize()) } pagingItems.loadState.refresh is LoadState.Error && pagingItems.itemCount == 0 -> { Box( @@ -1412,6 +1416,7 @@ fun FeedScreen( } }, onItemLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) if (!selectionMode) { selectionMode = true selectedIds = setOf(link.id) @@ -1471,6 +1476,7 @@ fun FeedScreen( } }, onItemLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) if (!selectionMode) { selectionMode = true selectedIds = setOf(link.id) @@ -1530,6 +1536,7 @@ fun FeedScreen( } }, onItemLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) if (!selectionMode) { selectionMode = true selectedIds = setOf(link.id) @@ -1581,18 +1588,25 @@ fun FeedScreen( contentColor = CyanPrimary ) } - } - } + } - if (selectedLink != null) { - LinkDetailsDialog( - link = selectedLink!!, - onDismiss = { selectedLink = null }, - onLinkClick = { url -> - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - context.startActivity(intent) - } - ) + // Link Details Overlay (Hero-like transition) + AnimatedVisibility( + visible = selectedLink != null, + enter = fadeIn(animationSpec = tween(300)) + scaleIn(initialScale = 0.9f, animationSpec = tween(300)), + exit = fadeOut(animationSpec = tween(200)) + scaleOut(targetScale = 0.9f, animationSpec = tween(200)), + modifier = Modifier.fillMaxSize() + ) { + selectedLink?.let { link -> + LinkDetailsView( + link = link, + onDismiss = { selectedLink = null }, + onLinkClick = { url -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + } + ) + } } if (showAddToCollectionDialog) { @@ -1633,5 +1647,6 @@ fun FeedScreen( } ) } + } } } diff --git a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt index 12a1785..8aa7794 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt @@ -18,6 +18,9 @@ import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.BrokenImage +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.HelpOutline +import androidx.compose.material.icons.filled.Warning import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.ui.window.DialogProperties @@ -30,11 +33,15 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.shaarit.domain.model.HealthStatus import com.shaarit.domain.model.ShaarliLink import com.shaarit.ui.components.GlassCard import com.shaarit.ui.components.TagChip import com.shaarit.ui.theme.* import dev.jeziellago.compose.markdowntext.MarkdownText +import coil.compose.AsyncImage +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.Color /** * Full list view item - shows all details including markdown description @@ -58,7 +65,7 @@ fun ListViewItem( if (showDeleteDialog) { DeleteConfirmationDialog( - linkTitle = link.title, + linkTitle = link.displayTitle, onConfirm = { onDeleteClick() showDeleteDialog = false @@ -76,12 +83,24 @@ fun ListViewItem( Column { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top ) { + // Thumbnail (List View) + if (!link.thumbnailUrl.isNullOrBlank()) { + AsyncImage( + model = link.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(100.dp) + .clip(RoundedCornerShape(12.dp)) + ) + } + Column(modifier = Modifier.weight(1f)) { Text( - text = link.title, + text = link.displayTitle, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = CyanPrimary, @@ -91,18 +110,18 @@ fun ListViewItem( Spacer(modifier = Modifier.height(4.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - if (link.isDeadLink) { - Icon( - imageVector = Icons.Default.BrokenImage, - contentDescription = "Lien mort", - tint = ErrorRed, - modifier = Modifier.size(16.dp).padding(end = 4.dp) - ) - } + HealthStatusIcon( + healthStatus = link.healthStatus, + modifier = Modifier.size(16.dp).padding(end = 4.dp) + ) Text( text = link.url, style = MaterialTheme.typography.bodySmall, - color = if (link.isDeadLink) ErrorRed else TealSecondary, + color = when (link.healthStatus) { + HealthStatus.DEAD -> ErrorRed + HealthStatus.OK -> TealSecondary + else -> TextMuted + }, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -230,7 +249,7 @@ fun GridViewItem( if (showDeleteDialog) { DeleteConfirmationDialog( - linkTitle = link.title, + linkTitle = link.displayTitle, onConfirm = { onDeleteClick() showDeleteDialog = false @@ -242,7 +261,7 @@ fun GridViewItem( GlassCard( modifier = Modifier .fillMaxWidth() - .height(200.dp), + .heightIn(min = 220.dp), onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() }, onLongClick = onItemLongClick, glowColor = if (isSelected) CyanPrimary else CyanPrimary @@ -252,26 +271,40 @@ fun GridViewItem( verticalArrangement = Arrangement.SpaceBetween ) { Column { + // Thumbnail (Grid View) + if (!link.thumbnailUrl.isNullOrBlank()) { + AsyncImage( + model = link.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(140.dp) + .padding(bottom = 12.dp) + .clip(RoundedCornerShape(12.dp)) + ) + } + // Title with pin indicator Row( verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() ) { - if (link.isDeadLink) { - Icon( - imageVector = Icons.Default.BrokenImage, - contentDescription = "Lien mort", - tint = ErrorRed, - modifier = Modifier.size(16.dp).padding(end = 4.dp).align(Alignment.CenterVertically) - ) - } + HealthStatusIcon( + healthStatus = link.healthStatus, + modifier = Modifier.size(16.dp).padding(end = 4.dp).align(Alignment.CenterVertically) + ) Text( - text = link.title, + text = link.displayTitle, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, - color = if (link.isDeadLink) ErrorRed else CyanPrimary, + color = when (link.healthStatus) { + HealthStatus.DEAD -> ErrorRed + HealthStatus.OK -> CyanPrimary + else -> CyanPrimary.copy(alpha = 0.7f) + }, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) @@ -431,7 +464,7 @@ fun CompactViewItem( if (showDeleteDialog) { DeleteConfirmationDialog( - linkTitle = link.title, + linkTitle = link.displayTitle, onConfirm = { onDeleteClick() showDeleteDialog = false @@ -489,19 +522,19 @@ fun CompactViewItem( Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { - if (link.isDeadLink) { - Icon( - imageVector = Icons.Default.BrokenImage, - contentDescription = "Lien mort", - tint = ErrorRed, - modifier = Modifier.size(14.dp).padding(end = 4.dp) - ) - } + HealthStatusIcon( + healthStatus = link.healthStatus, + modifier = Modifier.size(14.dp).padding(end = 4.dp) + ) Text( - text = link.title, + text = link.displayTitle, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, - color = if (link.isDeadLink) ErrorRed else CyanPrimary, + color = when (link.healthStatus) { + HealthStatus.DEAD -> ErrorRed + HealthStatus.OK -> CyanPrimary + else -> CyanPrimary.copy(alpha = 0.7f) + }, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -615,21 +648,33 @@ fun DeleteConfirmationDialog( /** * Dialog to show full link details */ +/** + * Full screen link details view (not a dialog window, so we can animate it) + */ +/** + * Full screen link details view (not a dialog window, so we can animate it) + */ @OptIn(ExperimentalLayoutApi::class) @Composable -fun LinkDetailsDialog( +fun LinkDetailsView( link: ShaarliLink, onDismiss: () -> Unit, onLinkClick: (String) -> Unit ) { - androidx.compose.ui.window.Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) + Box( + modifier = Modifier + .fillMaxSize() + .clickable(onClick = onDismiss) // Click outside to dismiss + .background(Color.Black.copy(alpha = 0.6f)), + contentAlignment = Alignment.Center ) { + // Stop propagation of clicks to the background GlassCard( modifier = Modifier - .fillMaxSize() .padding(16.dp) + .fillMaxWidth() + .fillMaxHeight(0.9f) + .clickable(enabled = false, onClick = {}) ) { Column( modifier = Modifier @@ -643,7 +688,7 @@ fun LinkDetailsDialog( verticalAlignment = Alignment.Top ) { Text( - text = link.title, + text = link.displayTitle, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = CyanPrimary, @@ -665,6 +710,20 @@ fun LinkDetailsDialog( Spacer(modifier = Modifier.height(16.dp)) + // Hero Image in Details + if (!link.thumbnailUrl.isNullOrBlank()) { + AsyncImage( + model = link.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .clip(RoundedCornerShape(16.dp)) + ) + Spacer(modifier = Modifier.height(16.dp)) + } + // Scrollable Content Column( modifier = Modifier @@ -754,3 +813,54 @@ fun LinkDetailsDialog( } } } + +/** + * Indicateur visuel du statut de santé d'un lien + * - NOTE: pas d'icône (c'est une note, pas un lien) + * - UNTESTED: icône grise (jamais testé) + * - OK: icône verte (testé et fonctionnel) + * - DEAD: icône rouge (testé et mort) + */ +@Composable +fun HealthStatusIcon( + healthStatus: HealthStatus, + modifier: Modifier = Modifier +) { + when (healthStatus) { + HealthStatus.NOTE -> { + // Pas d'icône pour les notes + } + HealthStatus.UNTESTED -> { + Icon( + imageVector = Icons.Default.HelpOutline, + contentDescription = "Non testé", + tint = TextMuted, + modifier = modifier + ) + } + HealthStatus.OK -> { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Lien fonctionnel", + tint = SuccessGreen, + modifier = modifier + ) + } + HealthStatus.PENDING -> { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = "En attente de confirmation", + tint = Color(0xFFFFA726), // Orange + modifier = modifier + ) + } + HealthStatus.DEAD -> { + Icon( + imageVector = Icons.Default.BrokenImage, + contentDescription = "Lien mort", + tint = ErrorRed, + modifier = modifier + ) + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt index 1db76d8..a97490e 100644 --- a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt +++ b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt @@ -104,6 +104,7 @@ fun AppNavGraph( }, navArgument("collectionId") { type = NavType.LongType + nullable = false defaultValue = -1L } ), diff --git a/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt index 271318b..205c53f 100644 --- a/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.LinkOff import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material3.* import androidx.compose.runtime.* @@ -225,6 +226,29 @@ fun SettingsScreen( onClick = { viewModel.scanAndClassify() } ) } + + // Health Check Section + item { + Spacer(modifier = Modifier.height(16.dp)) + SettingsSection(title = "Vérification des liens") + } + + item { + val totalBookmarks by viewModel.totalBookmarks.collectAsState() + val testedBookmarks by viewModel.testedBookmarks.collectAsState() + val deadLinksCount by viewModel.deadLinksCount.collectAsState() + val lastHealthCheckTime by viewModel.lastHealthCheckTime.collectAsState() + val isHealthCheckRunning by viewModel.isHealthCheckRunning.collectAsState() + + HealthCheckStatusItem( + totalBookmarks = totalBookmarks, + testedBookmarks = testedBookmarks, + deadLinksCount = deadLinksCount, + lastHealthCheckTime = lastHealthCheckTime, + isRunning = isHealthCheckRunning, + onStartCheck = { viewModel.triggerHealthCheck() } + ) + } // About Section item { @@ -540,3 +564,107 @@ private fun GeminiApiKeyItem( } } } + +@Composable +private fun HealthCheckStatusItem( + totalBookmarks: Int, + testedBookmarks: Int, + deadLinksCount: Int, + lastHealthCheckTime: Long?, + isRunning: Boolean, + onStartCheck: () -> Unit +) { + val dateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault()) + val lastCheckText = if (lastHealthCheckTime != null && lastHealthCheckTime > 0) { + dateFormat.format(Date(lastHealthCheckTime)) + } else { + "Jamais" + } + + val progressPercent = if (totalBookmarks > 0) { + (testedBookmarks * 100) / totalBookmarks + } else { + 0 + } + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onStartCheck, enabled = !isRunning) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + if (isRunning) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = Icons.Outlined.LinkOff, + contentDescription = null, + tint = if (deadLinksCount > 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (isRunning) "Vérification en cours..." else "Vérifier les liens", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "Dernier cycle: $lastCheckText", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Progress bar + LinearProgressIndicator( + progress = progressPercent / 100f, + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Stats row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "$testedBookmarks / $totalBookmarks testés ($progressPercent%)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (deadLinksCount > 0) { + Text( + text = "⚠️ $deadLinksCount liens morts", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } else if (testedBookmarks > 0) { + Text( + text = "✓ Tous fonctionnels", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt b/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt index 51335f2..9639002 100644 --- a/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt @@ -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 = _geminiApiKey.asStateFlow() + // Health Check Statistics + val totalBookmarks: StateFlow = linkDao.getTotalBookmarksCount() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + + val testedBookmarks: StateFlow = linkDao.getTestedBookmarksCount() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + + val deadLinksCount: StateFlow = linkDao.getDeadLinksCount() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + + val pendingLinksCount: StateFlow = linkDao.getPendingLinksCount() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + + val lastHealthCheckTime: StateFlow = linkDao.getLastHealthCheckTime() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + private val _isHealthCheckRunning = MutableStateFlow(false) + val isHealthCheckRunning: StateFlow = _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() + .build() + + workManager.enqueueUniqueWork( + "health_check_manual", + ExistingWorkPolicy.REPLACE, + request + ) + } + } + fun exportToJson(uri: Uri) { viewModelScope.launch { _uiState.value = _uiState.value.copy(isLoading = true) diff --git a/app/src/main/java/com/shaarit/ui/components/SkeletonLoader.kt b/app/src/main/java/com/shaarit/ui/components/SkeletonLoader.kt new file mode 100644 index 0000000..7d72707 --- /dev/null +++ b/app/src/main/java/com/shaarit/ui/components/SkeletonLoader.kt @@ -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() + } + } +} diff --git a/build_output.txt b/build_output.txt deleted file mode 100644 index 7fdaa55..0000000 --- a/build_output.txt +++ /dev/null @@ -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 diff --git a/compile_output.txt b/compile_output.txt deleted file mode 100644 index b95752a..0000000 --- a/compile_output.txt +++ /dev/null @@ -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 diff --git a/ANALYSE_ET_AMELIORATIONS.md b/docs/ANALYSE_ET_AMELIORATIONS.md similarity index 100% rename from ANALYSE_ET_AMELIORATIONS.md rename to docs/ANALYSE_ET_AMELIORATIONS.md diff --git a/docs/RAPPORT_AMELIORATION_v2.md b/docs/RAPPORT_AMELIORATION_v2.md new file mode 100644 index 0000000..bf44ff5 --- /dev/null +++ b/docs/RAPPORT_AMELIORATION_v2.md @@ -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. diff --git a/RELEASE_BUILD.md b/docs/RELEASE_BUILD.md similarity index 100% rename from RELEASE_BUILD.md rename to docs/RELEASE_BUILD.md