diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2ef01d4..c603fc4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -144,6 +144,9 @@ dependencies { // JSoup for HTML parsing (metadata extraction) implementation(libs.jsoup) + + // Google Gemini AI SDK + implementation("com.google.ai.client.generativeai:generativeai:0.9.0") testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 991a5ab..6cd5a1b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,7 +33,8 @@ android:name=".MainActivity" android:exported="true" android:label="@string/app_name" - android:theme="@style/Theme.ShaarIt.Splash"> + android:theme="@style/Theme.ShaarIt.Splash" + android:windowSoftInputMode="adjustResize"> diff --git a/app/src/main/java/com/shaarit/core/di/RepositoryModule.kt b/app/src/main/java/com/shaarit/core/di/RepositoryModule.kt index c2f8d19..2c0d833 100644 --- a/app/src/main/java/com/shaarit/core/di/RepositoryModule.kt +++ b/app/src/main/java/com/shaarit/core/di/RepositoryModule.kt @@ -1,8 +1,10 @@ package com.shaarit.core.di import com.shaarit.data.repository.AuthRepositoryImpl +import com.shaarit.data.repository.GeminiRepositoryImpl import com.shaarit.data.repository.LinkRepositoryImpl import com.shaarit.domain.repository.AuthRepository +import com.shaarit.domain.repository.GeminiRepository import com.shaarit.domain.repository.LinkRepository import dagger.Binds import dagger.Module @@ -17,4 +19,6 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository @Binds @Singleton abstract fun bindLinkRepository(impl: LinkRepositoryImpl): LinkRepository + + @Binds @Singleton abstract fun bindGeminiRepository(impl: GeminiRepositoryImpl): GeminiRepository } diff --git a/app/src/main/java/com/shaarit/core/storage/TokenManager.kt b/app/src/main/java/com/shaarit/core/storage/TokenManager.kt index 3838988..2553234 100644 --- a/app/src/main/java/com/shaarit/core/storage/TokenManager.kt +++ b/app/src/main/java/com/shaarit/core/storage/TokenManager.kt @@ -24,6 +24,10 @@ interface TokenManager { fun saveCollectionsConfigBookmarkId(id: Int) fun getCollectionsConfigBookmarkId(): Int? fun clearCollectionsConfigBookmarkId() + + fun saveGeminiApiKey(apiKey: String) + fun getGeminiApiKey(): String? + fun clearGeminiApiKey() } @Singleton @@ -112,6 +116,18 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte sharedPreferences.edit().remove(KEY_COLLECTIONS_BOOKMARK_ID).apply() } + override fun saveGeminiApiKey(apiKey: String) { + sharedPreferences.edit().putString(KEY_GEMINI_API_KEY, apiKey).apply() + } + + override fun getGeminiApiKey(): String? { + return sharedPreferences.getString(KEY_GEMINI_API_KEY, null) + } + + override fun clearGeminiApiKey() { + sharedPreferences.edit().remove(KEY_GEMINI_API_KEY).apply() + } + companion object { private const val KEY_TOKEN = "jwt_token" private const val KEY_BASE_URL = "base_url" @@ -119,5 +135,6 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte private const val KEY_COLLECTIONS_DIRTY = "collections_config_dirty" private const val KEY_COLLECTIONS_BOOKMARK_ID = "collections_config_bookmark_id" + private const val KEY_GEMINI_API_KEY = "gemini_api_key" } } diff --git a/app/src/main/java/com/shaarit/data/export/BookmarkImporter.kt b/app/src/main/java/com/shaarit/data/export/BookmarkImporter.kt index f6fc6c9..fae09c1 100644 --- a/app/src/main/java/com/shaarit/data/export/BookmarkImporter.kt +++ b/app/src/main/java/com/shaarit/data/export/BookmarkImporter.kt @@ -112,6 +112,12 @@ class BookmarkImporter @Inject constructor( return@forEach } + val contentType = try { + ContentType.valueOf(link.contentType) + } catch (e: Exception) { + ContentType.UNKNOWN + } + val entity = LinkEntity( id = 0, url = link.url, @@ -125,7 +131,7 @@ class BookmarkImporter @Inject constructor( siteName = link.siteName, thumbnailUrl = link.thumbnailUrl, readingTimeMinutes = link.readingTimeMinutes, - contentType = ContentType.valueOf(link.contentType), + contentType = contentType, syncStatus = SyncStatus.PENDING_CREATE ) @@ -194,10 +200,35 @@ class BookmarkImporter @Inject constructor( private fun detectContentType(url: String): ContentType { return when { url.contains("youtube.com") || url.contains("youtu.be") || - url.contains("vimeo.com") -> ContentType.VIDEO - url.contains("github.com") || url.contains("gitlab.com") -> ContentType.REPOSITORY - url.contains("soundcloud.com") || url.contains("spotify.com") -> ContentType.PODCAST + url.contains("vimeo.com") || url.contains("dailymotion.com") -> ContentType.VIDEO + + url.contains("github.com") || url.contains("gitlab.com") || + url.contains("bitbucket.org") -> ContentType.REPOSITORY + + url.contains("spotify.com") || url.contains("deezer.com") || + url.contains("soundcloud.com") || url.contains("music.apple.com") -> ContentType.MUSIC + + url.contains("docs.google.com") || url.contains("notion.so") || + url.contains("confluence") -> ContentType.DOCUMENT + + url.contains("facebook.com") || url.contains("instagram.com") || + url.contains("tiktok.com") || url.contains("twitter.com") || + url.contains("x.com") || url.contains("linkedin.com") || + url.contains("reddit.com") -> ContentType.SOCIAL + + url.contains("amazon") || url.contains("ebay") || + url.contains("shopify") -> ContentType.SHOPPING + + url.contains("news") || url.contains("nytimes") || url.contains("lemonde") || + url.contains("bbc") || url.contains("cnn") -> ContentType.NEWS + + url.matches(Regex(".*\\.(mp3|wav|ogg)$", RegexOption.IGNORE_CASE)) || + url.contains("podcast") -> ContentType.PODCAST + url.matches(Regex(".*\\.(jpg|jpeg|png|gif|webp)$", RegexOption.IGNORE_CASE)) -> ContentType.IMAGE + + url.endsWith(".pdf", ignoreCase = true) -> ContentType.PDF + else -> ContentType.ARTICLE } } 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 4580dac..e4bd7a2 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 @@ -10,6 +10,7 @@ import androidx.room.RawQuery 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.LinkEntity import com.shaarit.data.local.entity.LinkFtsEntity import com.shaarit.data.local.entity.SyncStatus @@ -158,6 +159,12 @@ interface LinkDao { @Query("UPDATE links SET is_pinned = :isPinned, sync_status = :syncStatus, local_modified_at = :timestamp WHERE id = :id") suspend fun updatePinStatus(id: Int, isPinned: Boolean, syncStatus: SyncStatus = SyncStatus.PENDING_UPDATE, timestamp: Long = System.currentTimeMillis()) + @Query("UPDATE links SET content_type = :contentType, site_name = :siteName, local_modified_at = :timestamp WHERE id = :id") + suspend fun updateLinkClassification(id: Int, contentType: ContentType, siteName: String?, timestamp: Long = System.currentTimeMillis()) + + @Query("UPDATE links SET tags = :tags, sync_status = 'PENDING_UPDATE', local_modified_at = :timestamp WHERE id = :id") + suspend fun updateLinkTags(id: Int, tags: List, timestamp: Long = System.currentTimeMillis()) + @Query("DELETE FROM links WHERE id = :id") suspend fun deleteLink(id: Int) 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 131659f..9e8b1a2 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 @@ -29,7 +29,7 @@ import com.shaarit.data.local.entity.TagEntity CollectionLinkCrossRef::class ], version = 1, - exportSchema = true + exportSchema = false ) @TypeConverters(Converters::class) abstract class ShaarliDatabase : RoomDatabase() { 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 d9ee575..174840e 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 @@ -97,7 +97,9 @@ enum class ContentType { DOCUMENT, // Google Docs, Notion, etc. SOCIAL, // Twitter, Mastodon, etc. SHOPPING, // Amazon, etc. - NEWSLETTER + NEWSLETTER, + MUSIC, // Spotify, Deezer, etc. + NEWS // News sites } /** diff --git a/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt b/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt index 688ea60..e91469c 100644 --- a/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt +++ b/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt @@ -176,10 +176,12 @@ class LinkMetadataExtractor @Inject constructor() { ogType == "article" || doc.select("article").isNotEmpty() -> ContentType.ARTICLE url.contains(Regex("github\\.com|gitlab\\.com|bitbucket")) -> ContentType.REPOSITORY url.contains(Regex("docs\\.google\\.com|notion\\.so|confluence")) -> ContentType.DOCUMENT - url.contains(Regex("twitter\\.com|x\\.com|mastodon")) -> ContentType.SOCIAL + url.contains(Regex("facebook\\.com|instagram\\.com|tiktok\\.com|twitter\\.com|x\\.com|mastodon|linkedin\\.com|reddit\\.com|snapchat\\.com|pinterest\\.com")) -> ContentType.SOCIAL url.contains(Regex("amazon|ebay|shopify")) -> ContentType.SHOPPING url.contains(Regex("substack|revue|mailchimp")) -> ContentType.NEWSLETTER - doc.select("audio").isNotEmpty() || url.contains(Regex("podcast|anchor|soundcloud")) -> ContentType.PODCAST + url.contains(Regex("spotify|deezer|soundcloud|bandcamp")) -> ContentType.MUSIC + url.contains(Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro")) -> ContentType.NEWS + doc.select("audio").isNotEmpty() || url.contains(Regex("podcast|anchor")) -> ContentType.PODCAST url.endsWith(".pdf") -> ContentType.PDF else -> ContentType.UNKNOWN } @@ -191,10 +193,13 @@ class LinkMetadataExtractor @Inject constructor() { private fun detectContentTypeFromUrl(url: String): ContentType { return when { url.contains(Regex("youtube\\.com|youtu\\.be|vimeo|dailymotion")) -> ContentType.VIDEO - url.contains(Regex("github\\.com|gitlab")) -> ContentType.REPOSITORY - url.contains(Regex("docs\\.google|notion\\.so")) -> ContentType.DOCUMENT - url.contains(Regex("twitter\\.com|x\\.com|mastodon")) -> ContentType.SOCIAL - url.contains(Regex("amazon|ebay")) -> ContentType.SHOPPING + url.contains(Regex("github\\.com|gitlab|bitbucket")) -> ContentType.REPOSITORY + url.contains(Regex("docs\\.google|notion\\.so|confluence")) -> ContentType.DOCUMENT + url.contains(Regex("facebook\\.com|instagram\\.com|tiktok\\.com|twitter\\.com|x\\.com|mastodon|linkedin\\.com|reddit\\.com|snapchat\\.com|pinterest\\.com")) -> ContentType.SOCIAL + url.contains(Regex("amazon|ebay|shopify")) -> ContentType.SHOPPING + url.contains(Regex("substack|revue|mailchimp")) -> ContentType.NEWSLETTER + url.contains(Regex("spotify|deezer|soundcloud|bandcamp")) -> ContentType.MUSIC + url.contains(Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro")) -> ContentType.NEWS url.endsWith(".pdf") -> ContentType.PDF else -> ContentType.UNKNOWN } @@ -243,7 +248,7 @@ class LinkMetadataExtractor @Inject constructor() { path.trim('/').split('/').lastOrNull() ?.replace('-', ' ') ?.replace('_', ' ') - ?.capitalize() + ?.replaceFirstChar { if (it.isLowerCase()) it.titlecase(java.util.Locale.getDefault()) else it.toString() } } catch (e: Exception) { null } diff --git a/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt new file mode 100644 index 0000000..fa861d7 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt @@ -0,0 +1,224 @@ +package com.shaarit.data.repository + +import com.google.ai.client.generativeai.GenerativeModel +import com.google.ai.client.generativeai.type.content +import com.google.ai.client.generativeai.type.generationConfig +import com.shaarit.core.storage.TokenManager +import com.shaarit.domain.model.AiContentType +import com.shaarit.domain.model.AiEnrichmentResult +import com.shaarit.domain.repository.GeminiRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +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 +) : GeminiRepository { + + // Cache en mémoire pour éviter de rappeler l'API pour la même URL durant la session + private val analysisCache = mutableMapOf() + + override fun isApiKeyConfigured(): Boolean { + return !tokenManager.getGeminiApiKey().isNullOrBlank() + } + + override suspend fun analyzeUrl(url: String): Result = withContext(Dispatchers.IO) { + // Vérifier le cache d'abord + if (analysisCache.containsKey(url)) { + return@withContext Result.success(analysisCache[url]!!) + } + + try { + val apiKey = tokenManager.getGeminiApiKey() + if (apiKey.isNullOrBlank()) { + return@withContext Result.failure(Exception("Clé API Gemini non configurée. Allez dans Paramètres pour la configurer.")) + } + + // Verify URL is accessible + if (!isUrlAccessible(url)) { + return@withContext Result.failure(Exception("L'URL n'est pas accessible. Vérifiez qu'elle est valide.")) + } + + // Liste des modèles à tester par ordre de préférence (du plus léger/récent au plus ancien) + // Basé sur les modèles disponibles pour l'utilisateur (v2.5 Flash Lite, v2.5 Flash, v3 Flash, etc.) + val modelsToTry = listOf( + "gemini-2.5-flash-lite", // Priorité 1 : Le plus léger et rapide + "gemini-2.5-flash", // Priorité 2 : Standard 2.5 + "gemini-3-flash", // Priorité 3 : Nouvelle génération 3 + "gemini-2.0-flash-lite", // Fallbacks... + "gemini-2.0-flash", + "gemini-1.5-flash", + "gemini-1.5-flash-8b" + ) + var lastException: Exception? = null + + for (modelName in modelsToTry) { + try { + val result = generateWithModel(apiKey, modelName, url) + // Mettre en cache le résultat réussi + analysisCache[url] = result + return@withContext Result.success(result) + } catch (e: Exception) { + lastException = e + val msg = (e.message ?: "").lowercase() + + // On continue d'essayer le modèle suivant SI : + // 1. Le modèle n'est pas trouvé (404) + // 2. Le quota est dépassé ou limite atteinte (429, quota, exhausted) + val isRetryable = msg.contains("404") || + msg.contains("not found") || + msg.contains("429") || + msg.contains("quota") || + msg.contains("exhausted") || + msg.contains("resource exhausted") + + if (!isRetryable) { + // Pour les autres erreurs (clé invalide, erreur serveur grave), on arrête + break + } + // Sinon on continue avec le modèle suivant dans la liste + } + } + + // Si on arrive ici, tous les modèles ont échoué + val errorMessage = lastException?.message ?: "Erreur inconnue" + val userMessage = when { + errorMessage.contains("404") && errorMessage.contains("not found") -> + "Modèles IA non disponibles. Vérifiez votre clé Google AI Studio." + errorMessage.contains("API key not valid") -> + "Clé API invalide." + errorMessage.contains("quota") -> + "Quota API dépassé." + errorMessage.contains("MissingFieldException") -> + "Erreur de communication avec l'API Gemini (Format de réponse inattendu)." + else -> "Erreur IA: ${errorMessage.take(100)}..." + } + Result.failure(Exception(userMessage)) + } catch (e: Exception) { + Result.failure(Exception("Erreur inattendue: ${e.message}")) + } + } + + private suspend fun generateWithModel(apiKey: String, modelName: String, url: String): AiEnrichmentResult { + val generativeModel = GenerativeModel( + modelName = modelName, + apiKey = apiKey, + generationConfig = generationConfig { + temperature = 0.7f + maxOutputTokens = 1024 + } + ) + + val prompt = buildPrompt(url) + + val response = generativeModel.generateContent( + content { + text(prompt) + } + ) + + val responseText = response.text ?: throw Exception("Réponse vide de Gemini") + + return parseGeminiResponse(responseText).getOrThrow() + } + + private fun isUrlAccessible(url: String): Boolean { + 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 + } catch (e: Exception) { + false + } + } + + private fun buildPrompt(url: String): String { + return """ +Analyse cette URL: $url + +Tu dois analyser le contenu de cette page web et générer les informations suivantes. + +Consignes strictes: +1. Génère un titre pertinent et concis (max 100 caractères) +2. Génère une description résumée du contenu (max 200 mots) +3. Génère une liste de 5 à 15 tags pertinents (en lowercase, sans espaces, utilisez des tirets si nécessaire) +4. Classe le contenu dans UN des types suivants: Article, Video, Tutorial, GitRepository, Other + +Réponds UNIQUEMENT en format JSON strict, sans markdown, sans commentaires, exactement comme ceci: +{ + "title": "Le titre ici", + "description": "La description ici", + "tags": ["tag1", "tag2", "tag3"], + "contentType": "Article" +} + +Important: +- Les tags doivent être en lowercase +- Le contentType doit être exactement l'une de ces valeurs: Article, Video, Tutorial, GitRepository, Other +- Ne pas inclure de backticks ou de formatage markdown + """.trimIndent() + } + + private fun parseGeminiResponse(responseText: String): Result { + return try { + // Clean response - remove markdown code blocks if present + val cleanedResponse = responseText + .replace("```json", "") + .replace("```", "") + .trim() + + val json = JSONObject(cleanedResponse) + + val title = json.optString("title", "").take(100) + val description = json.optString("description", "") + + val tagsArray = json.optJSONArray("tags") + val tags = mutableListOf() + if (tagsArray != null) { + for (i in 0 until tagsArray.length()) { + val tag = tagsArray.optString(i) + .lowercase() + .replace(" ", "-") + .take(50) + if (tag.isNotBlank()) { + tags.add(tag) + } + } + } + + val contentTypeStr = json.optString("contentType", "Other") + val contentType = when (contentTypeStr.lowercase()) { + "article" -> AiContentType.ARTICLE + "video", "vidéo" -> AiContentType.VIDEO + "tutorial", "tutoriel" -> AiContentType.TUTORIAL + "gitrepository", "git", "repository", "dépôt git" -> AiContentType.GIT_REPOSITORY + else -> AiContentType.OTHER + } + + if (title.isBlank()) { + return Result.failure(Exception("Impossible d'extraire un titre de la réponse")) + } + + Result.success( + AiEnrichmentResult( + title = title, + description = description, + tags = tags.take(15), + contentType = contentType + ) + ) + } catch (e: Exception) { + Result.failure(Exception("Erreur lors du parsing de la réponse Gemini: ${e.message}")) + } + } +} 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 5a8d3f7..6051e6f 100644 --- a/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt +++ b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt @@ -132,6 +132,65 @@ constructor( Result.failure(e) } } + + override suspend fun getAllLinks(): Result> { + return try { + val localLinks = linkDao.getAllLinksForStats() + Result.success(localLinks.map { it.toDomainModel() }) + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun updateLinkClassification( + id: Int, + contentType: String?, + siteName: String?, + tagsToAdd: List? + ): Result { + return try { + val type = try { + if (contentType != null) ContentType.valueOf(contentType) else ContentType.UNKNOWN + } catch (e: Exception) { + ContentType.UNKNOWN + } + + // Update classification + linkDao.updateLinkClassification(id, type, siteName) + + // Add tags if provided + if (!tagsToAdd.isNullOrEmpty()) { + val existingLink = linkDao.getLinkById(id) + if (existingLink != null) { + val currentTags = existingLink.tags.toMutableList() + var tagsChanged = false + + for (tag in tagsToAdd) { + if (tag !in currentTags) { + currentTags.add(tag) + tagsChanged = true + + // Update tag counts + val existingTag = tagDao.getTagByName(tag) + if (existingTag != null) { + tagDao.incrementOccurrences(tag) + } else { + tagDao.insertTag(TagEntity(tag, 1)) + } + } + } + + if (tagsChanged) { + linkDao.updateLinkTags(id, currentTags) + } + } + } + + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } // ====== Écriture (avec file d'attente de sync) ====== diff --git a/app/src/main/java/com/shaarit/domain/model/AiEnrichmentResult.kt b/app/src/main/java/com/shaarit/domain/model/AiEnrichmentResult.kt new file mode 100644 index 0000000..d6580d7 --- /dev/null +++ b/app/src/main/java/com/shaarit/domain/model/AiEnrichmentResult.kt @@ -0,0 +1,16 @@ +package com.shaarit.domain.model + +data class AiEnrichmentResult( + val title: String, + val description: String, + val tags: List, + val contentType: AiContentType +) + +enum class AiContentType(val displayName: String) { + ARTICLE("Article"), + VIDEO("Vidéo"), + TUTORIAL("Tutoriel"), + GIT_REPOSITORY("Dépôt Git"), + OTHER("Autre") +} diff --git a/app/src/main/java/com/shaarit/domain/repository/GeminiRepository.kt b/app/src/main/java/com/shaarit/domain/repository/GeminiRepository.kt new file mode 100644 index 0000000..aecb03b --- /dev/null +++ b/app/src/main/java/com/shaarit/domain/repository/GeminiRepository.kt @@ -0,0 +1,8 @@ +package com.shaarit.domain.repository + +import com.shaarit.domain.model.AiEnrichmentResult + +interface GeminiRepository { + suspend fun analyzeUrl(url: String): Result + fun isApiKeyConfigured(): Boolean +} diff --git a/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt b/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt index c40f532..5977efb 100644 --- a/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt +++ b/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt @@ -55,4 +55,8 @@ interface LinkRepository { suspend fun getTags(): Result> suspend fun getLinksByTag(tag: String): Result> + + suspend fun getAllLinks(): Result> + + suspend fun updateLinkClassification(id: Int, contentType: String?, siteName: String?, tagsToAdd: List? = null): Result } diff --git a/app/src/main/java/com/shaarit/domain/usecase/AnalyzeUrlWithAiUseCase.kt b/app/src/main/java/com/shaarit/domain/usecase/AnalyzeUrlWithAiUseCase.kt new file mode 100644 index 0000000..a6d751f --- /dev/null +++ b/app/src/main/java/com/shaarit/domain/usecase/AnalyzeUrlWithAiUseCase.kt @@ -0,0 +1,32 @@ +package com.shaarit.domain.usecase + +import com.shaarit.domain.model.AiEnrichmentResult +import com.shaarit.domain.repository.GeminiRepository +import javax.inject.Inject + +class AnalyzeUrlWithAiUseCase @Inject constructor( + private val geminiRepository: GeminiRepository +) { + suspend operator fun invoke(url: String): Result { + if (url.isBlank()) { + return Result.failure(Exception("L'URL ne peut pas être vide")) + } + + if (!isValidUrl(url)) { + return Result.failure(Exception("L'URL n'est pas valide")) + } + + return geminiRepository.analyzeUrl(url) + } + + fun isApiKeyConfigured(): Boolean = geminiRepository.isApiKeyConfigured() + + private fun isValidUrl(url: String): Boolean { + return try { + val pattern = Regex("^(https?://)[\\w\\-]+(\\.[\\w\\-]+)+.*$", RegexOption.IGNORE_CASE) + pattern.matches(url) + } catch (e: Exception) { + false + } + } +} diff --git a/app/src/main/java/com/shaarit/domain/usecase/ClassifyBookmarksUseCase.kt b/app/src/main/java/com/shaarit/domain/usecase/ClassifyBookmarksUseCase.kt new file mode 100644 index 0000000..912309c --- /dev/null +++ b/app/src/main/java/com/shaarit/domain/usecase/ClassifyBookmarksUseCase.kt @@ -0,0 +1,158 @@ +package com.shaarit.domain.usecase + +import com.shaarit.data.local.entity.ContentType +import com.shaarit.domain.model.ShaarliLink +import com.shaarit.domain.repository.LinkRepository +import kotlinx.coroutines.flow.firstOrNull +import java.net.URI +import javax.inject.Inject + +class ClassifyBookmarksUseCase @Inject constructor( + private val linkRepository: LinkRepository +) { + + suspend operator fun invoke(): Result { + return try { + val linksResult = linkRepository.getAllLinks() + + if (linksResult.isFailure) { + return Result.failure(linksResult.exceptionOrNull() ?: Exception("Failed to fetch links")) + } + + val links = linksResult.getOrNull() ?: emptyList() + var updatedCount = 0 + + for (link in links) { + val classification = classify(link.url, link.title, link.tags) + + val newContentTypeName = classification.first.name + val newSiteName = classification.second + val tagsToAdd = getTagsForContentType(classification.first) + + // Check if any tag needs to be added + val needsTagUpdate = tagsToAdd.any { it !in link.tags } + + if (classification.first != ContentType.UNKNOWN && + (newContentTypeName != link.contentType || newSiteName != link.siteName || needsTagUpdate)) { + + linkRepository.updateLinkClassification( + id = link.id, + contentType = newContentTypeName, + siteName = newSiteName, + tagsToAdd = tagsToAdd + ) + updatedCount++ + } + } + + Result.success(updatedCount) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Returns the tags corresponding to a ContentType for filtering in the drawer + */ + private fun getTagsForContentType(contentType: ContentType): List { + return when (contentType) { + ContentType.VIDEO -> listOf("video") + ContentType.MUSIC -> listOf("music") + ContentType.SOCIAL -> listOf("social") + ContentType.NEWS -> listOf("news") + ContentType.REPOSITORY -> listOf("repository", "dev") + ContentType.SHOPPING -> listOf("shopping") + ContentType.PODCAST -> listOf("podcast") + ContentType.ARTICLE -> listOf("article") + ContentType.DOCUMENT -> listOf("document") + ContentType.PDF -> listOf("pdf") + ContentType.IMAGE -> listOf("image") + ContentType.NEWSLETTER -> listOf("newsletter") + else -> emptyList() + } + } + + // Helper to classify based on URL, Title, Tags + fun classify(url: String, title: String?, tags: List): Pair { + val lowerUrl = url.lowercase() + val host = try { URI(url).host?.lowercase() } catch (e: Exception) { null } ?: "" + + // Site Name detection + val siteName = when { + host.contains("youtube.com") || host.contains("youtu.be") -> "YouTube" + host.contains("facebook.com") -> "Facebook" + host.contains("twitter.com") || host.contains("x.com") -> "Twitter" + host.contains("instagram.com") -> "Instagram" + host.contains("tiktok.com") -> "TikTok" + host.contains("linkedin.com") -> "LinkedIn" + host.contains("github.com") -> "GitHub" + host.contains("gitlab.com") -> "GitLab" + host.contains("medium.com") -> "Medium" + host.contains("reddit.com") -> "Reddit" + host.contains("spotify.com") -> "Spotify" + host.contains("deezer.com") -> "Deezer" + host.contains("soundcloud.com") -> "SoundCloud" + host.contains("amazon") -> "Amazon" + host.contains("netflix.com") -> "Netflix" + host.contains("stackoverflow.com") -> "StackOverflow" + host.contains("pinterest.com") -> "Pinterest" + host.contains("twitch.tv") -> "Twitch" + host.contains("wikipedia.org") -> "Wikipedia" + host.contains("nytimes.com") -> "NY Times" + host.contains("lemonde.fr") -> "Le Monde" + host.contains("bbc.com") || host.contains("bbc.co.uk") -> "BBC" + host.contains("cnn.com") -> "CNN" + else -> null + } + + // Content Type detection + val type = when { + // Music + host.contains("spotify.com") || host.contains("deezer.com") || + host.contains("soundcloud.com") || host.contains("music.apple.com") || + host.contains("bandcamp.com") -> ContentType.MUSIC + + // Video + host.contains("youtube.com") || host.contains("youtu.be") || + host.contains("vimeo.com") || host.contains("dailymotion.com") || + host.contains("twitch.tv") || host.contains("netflix.com") -> ContentType.VIDEO + + // Social + host.contains("facebook.com") || host.contains("instagram.com") || + host.contains("tiktok.com") || host.contains("twitter.com") || + host.contains("x.com") || host.contains("linkedin.com") || + host.contains("reddit.com") || host.contains("snapchat.com") || + host.contains("pinterest.com") -> ContentType.SOCIAL + + // Dev / Repository + host.contains("github.com") || host.contains("gitlab.com") || + host.contains("bitbucket.org") || host.contains("stackoverflow.com") -> ContentType.REPOSITORY + + // Shopping + host.contains("amazon") || host.contains("ebay") || + host.contains("etsy.com") || host.contains("aliexpress.com") -> ContentType.SHOPPING + + // Documents + host.contains("docs.google.com") || host.contains("drive.google.com") || + host.contains("notion.so") || host.contains("trello.com") || + host.contains("jira") || host.contains("confluence") -> ContentType.DOCUMENT + + // News (Simple heuristic) + host.contains("news") || host.contains("nytimes") || host.contains("lemonde") || + host.contains("bbc") || host.contains("cnn") || host.contains("reuters") || + host.contains("theguardian") || host.contains("lefigaro") -> ContentType.NEWS + + // PDF + lowerUrl.endsWith(".pdf") -> ContentType.PDF + + // Images + lowerUrl.endsWith(".jpg") || lowerUrl.endsWith(".png") || + lowerUrl.endsWith(".gif") || lowerUrl.endsWith(".webp") || + host.contains("imgur.com") || host.contains("flickr.com") -> ContentType.IMAGE + + else -> ContentType.UNKNOWN + } + + return Pair(type, siteName) + } +} diff --git a/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt b/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt index 92019f7..3438ab3 100644 --- a/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt @@ -1,6 +1,7 @@ package com.shaarit.presentation.add import androidx.compose.animation.* +import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -15,6 +16,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -33,6 +35,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage import com.shaarit.ui.components.* import com.shaarit.ui.theme.* +import com.shaarit.ui.theme.Purple import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @@ -62,6 +65,8 @@ fun AddLinkScreen( val scrollState = rememberScrollState() val coroutineScope = rememberCoroutineScope() + val aiEnrichmentState by viewModel.aiEnrichmentState.collectAsState() + // State pour l'éditeur Markdown avec barre d'outils flottante val markdownEditorState = rememberMarkdownEditorState() @@ -85,6 +90,19 @@ fun AddLinkScreen( } } + LaunchedEffect(Unit) { + viewModel.aiErrorMessage.collect { message -> + snackbarHostState.showSnackbar(message) + } + } + + LaunchedEffect(aiEnrichmentState) { + if (aiEnrichmentState is AiEnrichmentState.Success) { + snackbarHostState.showSnackbar("✨ Enrichissement IA appliqué !") + viewModel.resetAiEnrichmentState() + } + } + // Conflict Dialog if (uiState is AddLinkUiState.Conflict) { val conflict = uiState as AddLinkUiState.Conflict @@ -209,26 +227,39 @@ fun AddLinkScreen( icon = Icons.Default.Link, label = "URL" ) { - OutlinedTextField( - value = url, - onValueChange = { viewModel.url.value = it }, + Row( modifier = Modifier.fillMaxWidth(), - placeholder = { Text("https://example.com", color = TextMuted) }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - trailingIcon = { - if (isExtractingMetadata) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - color = CyanPrimary, - strokeWidth = 2.dp - ) - } - }, - colors = compactTextFieldColors(), - shape = RoundedCornerShape(8.dp), - textStyle = MaterialTheme.typography.bodyMedium - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = url, + onValueChange = { viewModel.url.value = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("https://example.com", color = TextMuted) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + trailingIcon = { + if (isExtractingMetadata) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = CyanPrimary, + strokeWidth = 2.dp + ) + } + }, + colors = compactTextFieldColors(), + shape = RoundedCornerShape(8.dp), + textStyle = MaterialTheme.typography.bodyMedium + ) + + // AI Magic Button + AiMagicButton( + onClick = { viewModel.analyzeUrlWithAi() }, + isLoading = aiEnrichmentState is AiEnrichmentState.Loading, + enabled = url.isNotBlank() && aiEnrichmentState !is AiEnrichmentState.Loading + ) + } // Thumbnail preview AnimatedVisibility( @@ -247,6 +278,13 @@ fun AddLinkScreen( "ARTICLE" -> Icons.Default.Article "PODCAST" -> Icons.Default.Headphones "REPOSITORY" -> Icons.Default.Code + "MUSIC" -> Icons.Default.MusicNote + "NEWS" -> Icons.Default.Newspaper + "SHOPPING" -> Icons.Default.ShoppingCart + "SOCIAL" -> Icons.Default.Share + "DOCUMENT" -> Icons.Default.Description + "PDF" -> Icons.Default.PictureAsPdf + "NEWSLETTER" -> Icons.Default.Email else -> Icons.Default.Web }, contentDescription = null, @@ -734,3 +772,54 @@ private fun compactTextFieldColors() = OutlinedTextFieldDefaults.colors( unfocusedContainerColor = CardBackground.copy(alpha = 0.2f) ) +/** + * Bouton Magie IA pour enrichir automatiquement les informations du bookmark + */ +@Composable +private fun AiMagicButton( + onClick: () -> Unit, + isLoading: Boolean, + enabled: Boolean, + modifier: Modifier = Modifier +) { + val infiniteTransition = rememberInfiniteTransition(label = "ai_button") + val shimmerAlpha by infiniteTransition.animateFloat( + initialValue = 0.6f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(800, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "shimmer" + ) + + Surface( + onClick = onClick, + enabled = enabled && !isLoading, + shape = RoundedCornerShape(10.dp), + color = if (enabled) Purple.copy(alpha = if (isLoading) shimmerAlpha * 0.3f else 0.15f) else SurfaceVariant, + border = if (enabled) androidx.compose.foundation.BorderStroke(1.5.dp, Purple) else null, + modifier = modifier.size(48.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = Purple, + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = Icons.Outlined.AutoAwesome, + contentDescription = "Magie IA", + tint = if (enabled) Purple else TextMuted, + modifier = Modifier.size(22.dp) + ) + } + } + } +} + 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 f792543..3579ec4 100644 --- a/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt @@ -4,12 +4,16 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shaarit.data.metadata.LinkMetadataExtractor +import com.shaarit.domain.model.AiEnrichmentResult import com.shaarit.domain.model.ShaarliTag import com.shaarit.domain.repository.AddLinkResult import com.shaarit.domain.repository.LinkRepository +import com.shaarit.domain.usecase.AnalyzeUrlWithAiUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn @@ -24,6 +28,7 @@ class AddLinkViewModel constructor( private val linkRepository: LinkRepository, private val metadataExtractor: LinkMetadataExtractor, + private val analyzeUrlWithAiUseCase: AnalyzeUrlWithAiUseCase, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -69,6 +74,12 @@ constructor( private val _tagSuggestions = MutableStateFlow>(emptyList()) val tagSuggestions = _tagSuggestions.asStateFlow() + private val _aiEnrichmentState = MutableStateFlow(AiEnrichmentState.Idle) + val aiEnrichmentState = _aiEnrichmentState.asStateFlow() + + private val _aiErrorMessage = MutableSharedFlow() + val aiErrorMessage = _aiErrorMessage.asSharedFlow() + // For conflict handling private var conflictLinkId: Int? = null @@ -211,6 +222,56 @@ constructor( addTag(_newTagInput.value) } + fun analyzeUrlWithAi() { + val currentUrl = url.value + if (currentUrl.isBlank()) { + viewModelScope.launch { + _aiErrorMessage.emit("Veuillez d'abord entrer une URL") + } + return + } + + if (!analyzeUrlWithAiUseCase.isApiKeyConfigured()) { + viewModelScope.launch { + _aiErrorMessage.emit("Clé API Gemini non configurée. Allez dans Paramètres.") + } + return + } + + viewModelScope.launch { + _aiEnrichmentState.value = AiEnrichmentState.Loading + + analyzeUrlWithAiUseCase(currentUrl) + .onSuccess { result -> + applyAiEnrichment(result) + _aiEnrichmentState.value = AiEnrichmentState.Success + } + .onFailure { error -> + _aiEnrichmentState.value = AiEnrichmentState.Idle + _aiErrorMessage.emit(error.message ?: "Erreur lors de l'analyse IA") + } + } + } + + private fun applyAiEnrichment(result: AiEnrichmentResult) { + title.value = result.title + description.value = result.description + + // Add AI-generated tags to existing tags (without duplicates) + val currentTags = _selectedTags.value.toMutableSet() + result.tags.forEach { tag -> + val cleanTag = tag.trim().lowercase() + if (cleanTag.isNotBlank()) { + currentTags.add(cleanTag) + } + } + _selectedTags.value = currentTags.toList() + } + + fun resetAiEnrichmentState() { + _aiEnrichmentState.value = AiEnrichmentState.Idle + } + fun removeTag(tag: String) { _selectedTags.value = _selectedTags.value - tag } @@ -347,3 +408,9 @@ enum class ContentType { BOOKMARK, NOTE } + +sealed class AiEnrichmentState { + object Idle : AiEnrichmentState() + object Loading : AiEnrichmentState() + object Success : AiEnrichmentState() +} diff --git a/app/src/main/java/com/shaarit/presentation/dashboard/DashboardScreen.kt b/app/src/main/java/com/shaarit/presentation/dashboard/DashboardScreen.kt index 999e67e..009251e 100644 --- a/app/src/main/java/com/shaarit/presentation/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/dashboard/DashboardScreen.kt @@ -266,6 +266,8 @@ private fun ContentTypeBar( ContentType.SOCIAL -> Triple(Icons.Default.Share, "Social", Color(0xFF03A9F4)) ContentType.SHOPPING -> Triple(Icons.Default.ShoppingCart, "Shopping", Color(0xFF2196F3)) ContentType.NEWSLETTER -> Triple(Icons.Default.Email, "Newsletter", Color(0xFF9C27B0)) + ContentType.MUSIC -> Triple(Icons.Default.MusicNote, "Musique", Color(0xFFE91E63)) + ContentType.NEWS -> Triple(Icons.Default.Newspaper, "Actualités", Color(0xFFF44336)) ContentType.UNKNOWN -> Triple(Icons.Default.Link, "Autre", Color(0xFF9E9E9E)) } diff --git a/app/src/main/java/com/shaarit/presentation/edit/EditLinkScreen.kt b/app/src/main/java/com/shaarit/presentation/edit/EditLinkScreen.kt index 3cc55e2..423461e 100644 --- a/app/src/main/java/com/shaarit/presentation/edit/EditLinkScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/edit/EditLinkScreen.kt @@ -1,6 +1,7 @@ package com.shaarit.presentation.edit import androidx.compose.animation.* +import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -15,6 +16,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -33,6 +35,7 @@ import coil.compose.AsyncImage import com.shaarit.presentation.add.ContentType import com.shaarit.ui.components.* import com.shaarit.ui.theme.* +import com.shaarit.ui.theme.Purple import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @@ -58,6 +61,8 @@ fun EditLinkScreen( val scrollState = rememberScrollState() val coroutineScope = rememberCoroutineScope() + val aiEnrichmentState by viewModel.aiEnrichmentState.collectAsState() + // State pour l'éditeur Markdown avec barre d'outils flottante val markdownEditorState = rememberMarkdownEditorState() @@ -76,6 +81,19 @@ fun EditLinkScreen( } } + LaunchedEffect(Unit) { + viewModel.aiErrorMessage.collect { message -> + snackbarHostState.showSnackbar(message) + } + } + + LaunchedEffect(aiEnrichmentState) { + if (aiEnrichmentState is AiEnrichmentState.Success) { + snackbarHostState.showSnackbar("✨ Enrichissement IA appliqué !") + viewModel.resetAiEnrichmentState() + } + } + Box( modifier = Modifier .fillMaxSize() @@ -179,17 +197,30 @@ fun EditLinkScreen( icon = Icons.Default.Link, label = "URL" ) { - OutlinedTextField( - value = url, - onValueChange = { viewModel.url.value = it }, + Row( modifier = Modifier.fillMaxWidth(), - placeholder = { Text("https://example.com", color = TextMuted) }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - colors = compactTextFieldColors(), - shape = RoundedCornerShape(8.dp), - textStyle = MaterialTheme.typography.bodyMedium - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = url, + onValueChange = { viewModel.url.value = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("https://example.com", color = TextMuted) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + colors = compactTextFieldColors(), + shape = RoundedCornerShape(8.dp), + textStyle = MaterialTheme.typography.bodyMedium + ) + + // AI Magic Button + AiMagicButton( + onClick = { viewModel.analyzeUrlWithAi() }, + isLoading = aiEnrichmentState is AiEnrichmentState.Loading, + enabled = url.isNotBlank() && aiEnrichmentState !is AiEnrichmentState.Loading + ) + } } } @@ -651,3 +682,54 @@ private fun compactTextFieldColors() = OutlinedTextFieldDefaults.colors( unfocusedContainerColor = CardBackground.copy(alpha = 0.2f) ) +/** + * Bouton Magie IA pour enrichir automatiquement les informations du bookmark + */ +@Composable +private fun AiMagicButton( + onClick: () -> Unit, + isLoading: Boolean, + enabled: Boolean, + modifier: Modifier = Modifier +) { + val infiniteTransition = rememberInfiniteTransition(label = "ai_button") + val shimmerAlpha by infiniteTransition.animateFloat( + initialValue = 0.6f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(800, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "shimmer" + ) + + Surface( + onClick = onClick, + enabled = enabled && !isLoading, + shape = RoundedCornerShape(10.dp), + color = if (enabled) Purple.copy(alpha = if (isLoading) shimmerAlpha * 0.3f else 0.15f) else SurfaceVariant, + border = if (enabled) androidx.compose.foundation.BorderStroke(1.5.dp, Purple) else null, + modifier = modifier.size(48.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = Purple, + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = Icons.Outlined.AutoAwesome, + contentDescription = "Magie IA", + tint = if (enabled) Purple else TextMuted, + modifier = Modifier.size(22.dp) + ) + } + } + } +} + 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 59811f7..d96bd22 100644 --- a/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt @@ -3,12 +3,16 @@ package com.shaarit.presentation.edit import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.shaarit.domain.model.AiEnrichmentResult import com.shaarit.domain.model.ShaarliTag import com.shaarit.domain.repository.LinkRepository +import com.shaarit.domain.usecase.AnalyzeUrlWithAiUseCase import com.shaarit.presentation.add.ContentType import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -16,7 +20,8 @@ import kotlinx.coroutines.launch class EditLinkViewModel @Inject constructor( - private val linkRepository: LinkRepository, + private val linkRepository: LinkRepository, + private val analyzeUrlWithAiUseCase: AnalyzeUrlWithAiUseCase, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -49,6 +54,12 @@ constructor( private val _tagSuggestions = MutableStateFlow>(emptyList()) val tagSuggestions = _tagSuggestions.asStateFlow() + private val _aiEnrichmentState = MutableStateFlow(AiEnrichmentState.Idle) + val aiEnrichmentState = _aiEnrichmentState.asStateFlow() + + private val _aiErrorMessage = MutableSharedFlow() + val aiErrorMessage = _aiErrorMessage.asSharedFlow() + init { loadLink() loadAvailableTags() @@ -145,6 +156,56 @@ constructor( addTag(_newTagInput.value) } + fun analyzeUrlWithAi() { + val currentUrl = url.value + if (currentUrl.isBlank()) { + viewModelScope.launch { + _aiErrorMessage.emit("Veuillez d'abord entrer une URL") + } + return + } + + if (!analyzeUrlWithAiUseCase.isApiKeyConfigured()) { + viewModelScope.launch { + _aiErrorMessage.emit("Clé API Gemini non configurée. Allez dans Paramètres.") + } + return + } + + viewModelScope.launch { + _aiEnrichmentState.value = AiEnrichmentState.Loading + + analyzeUrlWithAiUseCase(currentUrl) + .onSuccess { result -> + applyAiEnrichment(result) + _aiEnrichmentState.value = AiEnrichmentState.Success + } + .onFailure { error -> + _aiEnrichmentState.value = AiEnrichmentState.Idle + _aiErrorMessage.emit(error.message ?: "Erreur lors de l'analyse IA") + } + } + } + + private fun applyAiEnrichment(result: AiEnrichmentResult) { + title.value = result.title + description.value = result.description + + // Add AI-generated tags to existing tags (without duplicates) + val currentTags = _selectedTags.value.toMutableSet() + result.tags.forEach { tag -> + val cleanTag = tag.trim().lowercase() + if (cleanTag.isNotBlank()) { + currentTags.add(cleanTag) + } + } + _selectedTags.value = currentTags.toList() + } + + fun resetAiEnrichmentState() { + _aiEnrichmentState.value = AiEnrichmentState.Idle + } + fun removeTag(tag: String) { _selectedTags.value = _selectedTags.value - tag } @@ -199,3 +260,9 @@ sealed class EditLinkUiState { object Success : EditLinkUiState() data class Error(val message: String) : EditLinkUiState() } + +sealed class AiEnrichmentState { + object Idle : AiEnrichmentState() + object Loading : AiEnrichmentState() + object Success : AiEnrichmentState() +} 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 46a2c4e..5f405b6 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt @@ -271,6 +271,7 @@ fun FeedScreen( onNavigateToCollections: () -> Unit = {}, onNavigateToSettings: () -> Unit = {}, onNavigateToRandom: () -> Unit = {}, + onNavigateToHelp: () -> Unit = {}, initialTagFilter: String? = null, initialCollectionId: Long? = null, viewModel: FeedViewModel = hiltViewModel() @@ -295,7 +296,6 @@ fun FeedScreen( var selectionMode by remember { mutableStateOf(false) } var selectedIds by remember { mutableStateOf(setOf()) } var showAddToCollectionDialog by remember { mutableStateOf(false) } - var showHelpDialog by remember { mutableStateOf(false) } // États des accordéons du drawer var mainMenuExpanded by remember { mutableStateOf(true) } @@ -430,7 +430,7 @@ fun FeedScreen( label = "Aide", onClick = { scope.launch { drawerState.close() } - showHelpDialog = true + onNavigateToHelp() } ) } @@ -519,6 +519,51 @@ fun FeedScreen( viewModel.setTagFilter("podcast") } ) + + DrawerNavigationItem( + icon = Icons.Default.MusicNote, + label = "Musique", + onClick = { + scope.launch { drawerState.close() } + viewModel.setTagFilter("music") + } + ) + + DrawerNavigationItem( + icon = Icons.Default.Newspaper, + label = "Actualités", + onClick = { + scope.launch { drawerState.close() } + viewModel.setTagFilter("news") + } + ) + + DrawerNavigationItem( + icon = Icons.Default.Share, + label = "Réseaux sociaux", + onClick = { + scope.launch { drawerState.close() } + viewModel.setTagFilter("social") + } + ) + + DrawerNavigationItem( + icon = Icons.Default.Code, + label = "Dépôts Git", + onClick = { + scope.launch { drawerState.close() } + viewModel.setTagFilter("repository") + } + ) + + DrawerNavigationItem( + icon = Icons.Default.ShoppingCart, + label = "Shopping", + onClick = { + scope.launch { drawerState.close() } + viewModel.setTagFilter("shopping") + } + ) } } @@ -1171,9 +1216,12 @@ fun FeedScreen( ) if (collectionId != null) { + val collectionName = remember(collectionId, collections) { + collections.find { it.id == collectionId }?.name ?: "Collection #$collectionId" + } AssistChip( onClick = { viewModel.clearCollectionFilter() }, - label = { Text("Collection #$collectionId") }, + label = { Text(collectionName) }, colors = AssistChipDefaults.assistChipColors( containerColor = CardBackground, labelColor = CyanPrimary @@ -1208,7 +1256,10 @@ fun FeedScreen( } IconButton( - onClick = { viewModel.clearTagFilter() }, + onClick = { + viewModel.clearTagFilter() + viewModel.clearCollectionFilter() + }, modifier = Modifier.size(32.dp) ) { Icon( @@ -1521,6 +1572,7 @@ fun FeedScreen( ) } } + } if (selectedLink != null) { LinkDetailsDialog( @@ -1571,25 +1623,5 @@ fun FeedScreen( } ) } - - if (showHelpDialog) { - AlertDialog( - onDismissRequest = { showHelpDialog = false }, - title = { Text("Aide") }, - text = { - Text( - "- Appui long sur un bookmark: active la sélection multiple\n" + - "- Bouton dossier: ajoute les éléments sélectionnés à une collection\n" + - "- Le menu (☰) permet de filtrer par collection ou tag" - ) - }, - confirmButton = { - TextButton(onClick = { showHelpDialog = false }) { - Text("OK") - } - } - ) - } } } -} diff --git a/app/src/main/java/com/shaarit/presentation/help/HelpScreen.kt b/app/src/main/java/com/shaarit/presentation/help/HelpScreen.kt new file mode 100644 index 0000000..152bc8b --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/help/HelpScreen.kt @@ -0,0 +1,937 @@ +package com.shaarit.presentation.help + +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.shaarit.ui.theme.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HelpScreen( + onNavigateBack: () -> Unit +) { + var expandedSection by remember { mutableStateOf("intro") } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient(colors = listOf(DeepNavy, DarkNavy)) + ) + ) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + "Guide d'utilisation", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "Retour", + tint = TextPrimary + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = DeepNavy.copy(alpha = 0.9f), + titleContentColor = TextPrimary + ) + ) + }, + containerColor = Color.Transparent + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Header + HelpHeader() + + Spacer(modifier = Modifier.height(8.dp)) + + // Introduction + HelpSection( + title = "Bienvenue sur ShaarIt", + icon = Icons.Default.Bookmark, + isExpanded = expandedSection == "intro", + onToggle = { expandedSection = if (expandedSection == "intro") null else "intro" } + ) { + HelpText( + "ShaarIt est votre gestionnaire de liens personnel qui se synchronise avec votre serveur Shaarli. " + + "Cette application vous permet de sauvegarder, organiser et retrouver facilement tous vos liens favoris." + ) + Spacer(modifier = Modifier.height(12.dp)) + HelpFeatureList( + listOf( + "Synchronisation bidirectionnelle avec Shaarli", + "Classification automatique des contenus", + "Organisation par collections et tags", + "Recherche avancée avec filtres multiples", + "Mode hors-ligne avec synchronisation différée", + "Import/Export de bookmarks" + ) + ) + } + + // Menu Principal + HelpSection( + title = "Menu Principal", + icon = Icons.Default.Menu, + isExpanded = expandedSection == "menu", + onToggle = { expandedSection = if (expandedSection == "menu") null else "menu" } + ) { + HelpText( + "Le menu latéral (accessible via l'icône ☰ en haut à gauche) est le centre de navigation de l'application. " + + "Il se compose de plusieurs sections organisées en accordéons." + ) + + Spacer(modifier = Modifier.height(16.dp)) + + HelpSubsection( + title = "Collections", + icon = Icons.Default.Folder, + description = "Accédez à la gestion complète de vos collections. Créez des dossiers pour organiser vos liens par thème, projet ou catégorie personnalisée." + ) + + HelpSubsection( + title = "Notes", + icon = Icons.Default.StickyNote2, + description = "Filtrez pour afficher uniquement vos notes personnelles. Les notes sont des entrées sans URL, idéales pour des mémos ou des idées à conserver." + ) + + HelpSubsection( + title = "Tags", + icon = Icons.Default.Label, + description = "Explorez tous vos tags et voyez leur fréquence d'utilisation. Cliquez sur un tag pour filtrer les liens associés." + ) + + HelpSubsection( + title = "Paramètres", + icon = Icons.Default.Settings, + description = "Configurez l'application, gérez les exports/imports, synchronisez manuellement et lancez le scan de classification." + ) + } + + // Types de Contenu + HelpSection( + title = "Types de Contenu", + icon = Icons.Default.Category, + isExpanded = expandedSection == "types", + onToggle = { expandedSection = if (expandedSection == "types") null else "types" } + ) { + HelpText( + "ShaarIt classe automatiquement vos liens par type de contenu. Cette classification permet de filtrer rapidement selon le format du contenu." + ) + + Spacer(modifier = Modifier.height(16.dp)) + + HelpSubsection( + title = "Tous les contenus", + icon = Icons.Default.AllInclusive, + description = "Affiche l'ensemble de vos liens sans filtre de type." + ) + + HelpSubsection( + title = "Bookmarks uniquement", + icon = Icons.Default.Bookmark, + description = "Exclut les notes pour n'afficher que les liens avec URL." + ) + + HelpSubsection( + title = "Notes uniquement", + icon = Icons.Default.StickyNote2, + description = "Affiche uniquement les entrées de type note (sans URL)." + ) + + HelpSubsection( + title = "Vidéos", + icon = Icons.Default.PlayCircle, + description = "Liens vers YouTube, Vimeo, Dailymotion et autres plateformes vidéo." + ) + + HelpSubsection( + title = "Articles", + icon = Icons.Default.Article, + description = "Pages web identifiées comme articles de blog ou d'actualité." + ) + + HelpSubsection( + title = "Développement", + icon = Icons.Default.Code, + description = "Contenus liés au développement logiciel (documentation, tutoriels, Stack Overflow)." + ) + + HelpSubsection( + title = "Podcasts", + icon = Icons.Default.Headphones, + description = "Liens vers des épisodes de podcasts et plateformes audio." + ) + + HelpSubsection( + title = "Musique", + icon = Icons.Default.MusicNote, + description = "Spotify, Deezer, SoundCloud, Bandcamp et autres services musicaux." + ) + + HelpSubsection( + title = "Actualités", + icon = Icons.Default.Newspaper, + description = "Articles de grands médias (NYTimes, Le Monde, BBC, etc.)." + ) + + HelpSubsection( + title = "Réseaux sociaux", + icon = Icons.Default.Share, + description = "Publications Facebook, Twitter/X, Instagram, LinkedIn, Reddit, TikTok, etc." + ) + + HelpSubsection( + title = "Dépôts Git", + icon = Icons.Default.Code, + description = "Projets GitHub, GitLab, Bitbucket." + ) + + HelpSubsection( + title = "Shopping", + icon = Icons.Default.ShoppingCart, + description = "Liens vers Amazon, eBay et autres boutiques en ligne." + ) + } + + // Classification Automatique + HelpSection( + title = "Classification Automatique", + icon = Icons.Default.AutoAwesome, + isExpanded = expandedSection == "scan", + onToggle = { expandedSection = if (expandedSection == "scan") null else "scan" } + ) { + HelpText( + "Le système de scan et classification analyse automatiquement vos liens pour déterminer leur type et extraire des métadonnées enrichies." + ) + + Spacer(modifier = Modifier.height(16.dp)) + + HelpInfoCard( + title = "Comment fonctionne le scan ?", + content = "Lorsque vous ajoutez un nouveau lien ou lancez un scan manuel depuis les paramètres, " + + "ShaarIt analyse l'URL et, si possible, récupère la page web pour extraire les métadonnées." + ) + + Spacer(modifier = Modifier.height(12.dp)) + + HelpSubtitle("Critères de classification :") + + Spacer(modifier = Modifier.height(8.dp)) + + HelpNumberedList( + listOf( + Pair("Analyse de l'URL", "Le domaine et le chemin sont examinés pour identifier les plateformes connues (YouTube, GitHub, Twitter, etc.)."), + Pair("Balises OpenGraph", "Les métadonnées og:type, og:title, og:description et og:image sont extraites pour enrichir l'affichage."), + Pair("Structure HTML", "La présence de balises
,