feat: Integrate Google Gemini AI for bookmark enrichment and improve content classification

- Add Google Gemini AI SDK dependency (generativeai:0.9.0)
- Implement GeminiRepository with API key management in TokenManager
- Create AI enrichment feature with loading states and error handling in AddLinkViewModel
- Add AI magic button with shimmer animation to AddLinkScreen for automatic bookmark analysis
- Extend ContentType enum with MUSIC and NEWS categories
- Enhance content type detection with expande
This commit is contained in:
Bruno Charest 2026-01-31 11:19:41 -05:00
parent 4021aacc1d
commit f88b7ffad3
28 changed files with 2132 additions and 71 deletions

View File

@ -145,6 +145,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)
androidTestImplementation(libs.androidx.espresso.core)

View File

@ -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">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View File

@ -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
}

View File

@ -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"
}
}

View File

@ -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
}
}

View File

@ -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<String>, timestamp: Long = System.currentTimeMillis())
@Query("DELETE FROM links WHERE id = :id")
suspend fun deleteLink(id: Int)

View File

@ -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() {

View File

@ -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
}
/**

View File

@ -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
}

View File

@ -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<String, AiEnrichmentResult>()
override fun isApiKeyConfigured(): Boolean {
return !tokenManager.getGeminiApiKey().isNullOrBlank()
}
override suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult> = 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<AiEnrichmentResult> {
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<String>()
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}"))
}
}
}

View File

@ -133,6 +133,65 @@ constructor(
}
}
override suspend fun getAllLinks(): Result<List<ShaarliLink>> {
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<String>?
): Result<Unit> {
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) ======
override suspend fun addLink(

View File

@ -0,0 +1,16 @@
package com.shaarit.domain.model
data class AiEnrichmentResult(
val title: String,
val description: String,
val tags: List<String>,
val contentType: AiContentType
)
enum class AiContentType(val displayName: String) {
ARTICLE("Article"),
VIDEO("Vidéo"),
TUTORIAL("Tutoriel"),
GIT_REPOSITORY("Dépôt Git"),
OTHER("Autre")
}

View File

@ -0,0 +1,8 @@
package com.shaarit.domain.repository
import com.shaarit.domain.model.AiEnrichmentResult
interface GeminiRepository {
suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult>
fun isApiKeyConfigured(): Boolean
}

View File

@ -55,4 +55,8 @@ interface LinkRepository {
suspend fun getTags(): Result<List<ShaarliTag>>
suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>>
suspend fun getAllLinks(): Result<List<ShaarliLink>>
suspend fun updateLinkClassification(id: Int, contentType: String?, siteName: String?, tagsToAdd: List<String>? = null): Result<Unit>
}

View File

@ -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<AiEnrichmentResult> {
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
}
}
}

View File

@ -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<Int> {
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<String> {
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<String>): Pair<ContentType, String?> {
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)
}
}

View File

@ -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)
)
}
}
}
}

View File

@ -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<List<ShaarliTag>>(emptyList())
val tagSuggestions = _tagSuggestions.asStateFlow()
private val _aiEnrichmentState = MutableStateFlow<AiEnrichmentState>(AiEnrichmentState.Idle)
val aiEnrichmentState = _aiEnrichmentState.asStateFlow()
private val _aiErrorMessage = MutableSharedFlow<String>()
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()
}

View File

@ -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))
}

View File

@ -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)
)
}
}
}
}

View File

@ -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
@ -17,6 +21,7 @@ class EditLinkViewModel
@Inject
constructor(
private val linkRepository: LinkRepository,
private val analyzeUrlWithAiUseCase: AnalyzeUrlWithAiUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() {
@ -49,6 +54,12 @@ constructor(
private val _tagSuggestions = MutableStateFlow<List<ShaarliTag>>(emptyList())
val tagSuggestions = _tagSuggestions.asStateFlow()
private val _aiEnrichmentState = MutableStateFlow<AiEnrichmentState>(AiEnrichmentState.Idle)
val aiEnrichmentState = _aiEnrichmentState.asStateFlow()
private val _aiErrorMessage = MutableSharedFlow<String>()
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()
}

View File

@ -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<Int>()) }
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")
}
}
)
}
}
}
}

View File

@ -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<String?>("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 <article>, <audio>, <video> influence la classification."),
Pair("Mots-clés du domaine", "Les termes comme 'news', 'podcast', 'shop' dans l'URL aident à la catégorisation.")
)
)
Spacer(modifier = Modifier.height(12.dp))
HelpInfoCard(
title = "Tags automatiques",
content = "Lors de la classification, des tags sont automatiquement ajoutés selon le type détecté : " +
"'video', 'article', 'dev', 'podcast', 'music', 'news', 'social', 'repository', 'shopping'. " +
"Ces tags permettent de filtrer rapidement par type de contenu."
)
Spacer(modifier = Modifier.height(12.dp))
HelpInfoCard(
title = "Lancer un scan manuel",
content = "Allez dans Paramètres > Maintenance > Scanner et Classer pour analyser tous vos liens existants. " +
"Cette opération peut prendre du temps selon le nombre de liens."
)
}
// Collections
HelpSection(
title = "Collections",
icon = Icons.Default.Folder,
isExpanded = expandedSection == "collections",
onToggle = { expandedSection = if (expandedSection == "collections") null else "collections" }
) {
HelpText(
"Les collections vous permettent d'organiser vos liens en groupes logiques. Il existe deux types de collections."
)
Spacer(modifier = Modifier.height(16.dp))
HelpInfoCard(
title = "Collections Manuelles",
content = "Créez des dossiers personnalisés et ajoutez-y des liens manuellement. " +
"Idéal pour regrouper des liens par projet, thème ou priorité."
)
Spacer(modifier = Modifier.height(12.dp))
HelpInfoCard(
title = "Collections Intelligentes",
content = "Basées sur une requête de tags, elles se mettent à jour automatiquement. " +
"Par exemple, une collection avec la requête 'dev python' affichera tous les liens ayant ces deux tags."
)
Spacer(modifier = Modifier.height(16.dp))
HelpSubtitle("Gérer les collections :")
Spacer(modifier = Modifier.height(8.dp))
HelpBulletList(
listOf(
"Créer : Bouton + dans l'écran Collections",
"Modifier : Menu contextuel (⋮) sur une collection",
"Supprimer : Menu contextuel > Supprimer",
"Ajouter des liens : Sélection multiple (appui long) puis icône dossier"
)
)
}
// Filtres et Tri
HelpSection(
title = "Filtres et Tri",
icon = Icons.Default.FilterList,
isExpanded = expandedSection == "filters",
onToggle = { expandedSection = if (expandedSection == "filters") null else "filters" }
) {
HelpText(
"L'icône filtre dans la barre d'outils donne accès à de nombreuses options pour affiner l'affichage de vos liens."
)
Spacer(modifier = Modifier.height(16.dp))
HelpSubtitle("Tri")
Spacer(modifier = Modifier.height(8.dp))
HelpBulletList(
listOf(
"Plus récent d'abord : Les derniers liens ajoutés en tête",
"Plus ancien d'abord : Ordre chronologique inversé"
)
)
Spacer(modifier = Modifier.height(12.dp))
HelpSubtitle("Période")
Spacer(modifier = Modifier.height(8.dp))
HelpBulletList(
listOf(
"Tous les bookmarks : Aucun filtre temporel",
"Aujourd'hui : Liens ajoutés dans les dernières 24h",
"Cette semaine : Liens des 7 derniers jours",
"Ce mois-ci : Liens des 30 derniers jours"
)
)
Spacer(modifier = Modifier.height(12.dp))
HelpSubtitle("Visibilité")
Spacer(modifier = Modifier.height(8.dp))
HelpBulletList(
listOf(
"Publics et Privés : Affiche tout",
"Publics uniquement : Liens visibles sur votre Shaarli public",
"Privés uniquement : Liens cachés du public"
)
)
Spacer(modifier = Modifier.height(12.dp))
HelpSubtitle("Tags")
Spacer(modifier = Modifier.height(8.dp))
HelpBulletList(
listOf(
"Tous les tags : Pas de filtre par tag",
"Sans tags : Affiche uniquement les liens non tagués"
)
)
Spacer(modifier = Modifier.height(12.dp))
HelpTip("Utilisez le bouton 'Réinitialiser les filtres' pour revenir aux paramètres par défaut.")
}
// Styles d'affichage
HelpSection(
title = "Styles d'Affichage",
icon = Icons.Default.ViewModule,
isExpanded = expandedSection == "views",
onToggle = { expandedSection = if (expandedSection == "views") null else "views" }
) {
HelpText(
"Choisissez le style d'affichage qui correspond à vos préférences via l'icône de vue dans la barre d'outils."
)
Spacer(modifier = Modifier.height(16.dp))
HelpSubsection(
title = "Vue Liste",
icon = Icons.Default.ViewStream,
description = "Affichage détaillé avec titre, description, tags et miniature. Idéal pour parcourir et lire les détails."
)
HelpSubsection(
title = "Vue Grille",
icon = Icons.Default.ViewModule,
description = "Affichage en cartes sur 2 colonnes avec miniatures proéminentes. Parfait pour les contenus visuels."
)
HelpSubsection(
title = "Vue Compacte",
icon = Icons.Default.ViewList,
description = "Liste condensée montrant plus de liens à l'écran. Efficace pour un survol rapide."
)
}
// Paramètres
HelpSection(
title = "Paramètres",
icon = Icons.Default.Settings,
isExpanded = expandedSection == "settings",
onToggle = { expandedSection = if (expandedSection == "settings") null else "settings" }
) {
HelpText(
"L'écran des paramètres vous permet de configurer et maintenir votre application."
)
Spacer(modifier = Modifier.height(16.dp))
HelpSubtitle("Analytiques")
Spacer(modifier = Modifier.height(8.dp))
HelpBulletList(
listOf(
"Tableau de bord : Statistiques d'utilisation, répartition des tags et types de contenu"
)
)
Spacer(modifier = Modifier.height(12.dp))
HelpSubtitle("Export")
Spacer(modifier = Modifier.height(8.dp))
HelpBulletList(
listOf(
"JSON : Format complet avec toutes les métadonnées, réimportable",
"CSV : Compatible Excel pour analyse dans un tableur",
"HTML : Format Netscape/Chrome pour import dans un navigateur"
)
)
Spacer(modifier = Modifier.height(12.dp))
HelpSubtitle("Import")
Spacer(modifier = Modifier.height(8.dp))
HelpBulletList(
listOf(
"JSON : Restaurez une sauvegarde ShaarIt",
"HTML : Importez les favoris de Chrome, Firefox ou autre navigateur"
)
)
Spacer(modifier = Modifier.height(12.dp))
HelpSubtitle("Synchronisation")
Spacer(modifier = Modifier.height(8.dp))
HelpText(
"Affiche l'état de synchronisation avec votre serveur Shaarli. " +
"Cliquez pour forcer une synchronisation manuelle. " +
"En mode hors-ligne, les modifications sont mises en file d'attente."
)
Spacer(modifier = Modifier.height(12.dp))
HelpSubtitle("Maintenance")
Spacer(modifier = Modifier.height(8.dp))
HelpBulletList(
listOf(
"Scanner et Classer : Lance l'analyse de tous vos liens pour détecter les types de contenu et enrichir les métadonnées"
)
)
}
// Gestes et Actions
HelpSection(
title = "Gestes et Actions",
icon = Icons.Default.TouchApp,
isExpanded = expandedSection == "gestures",
onToggle = { expandedSection = if (expandedSection == "gestures") null else "gestures" }
) {
HelpText("Maîtrisez les interactions pour naviguer efficacement dans l'application.")
Spacer(modifier = Modifier.height(16.dp))
HelpSubtitle("Sur un lien :")
Spacer(modifier = Modifier.height(8.dp))
HelpBulletList(
listOf(
"Appui simple : Ouvre le lien dans le navigateur",
"Appui long : Active le mode sélection multiple",
"Glisser vers le bas : Rafraîchit la liste (pull-to-refresh)"
)
)
Spacer(modifier = Modifier.height(12.dp))
HelpSubtitle("Mode sélection :")
Spacer(modifier = Modifier.height(8.dp))
HelpBulletList(
listOf(
"Appui sur d'autres liens : Ajoute/retire de la sélection",
"Icône dossier : Ajoute les liens sélectionnés à une collection",
"Icône X : Quitte le mode sélection"
)
)
Spacer(modifier = Modifier.height(12.dp))
HelpSubtitle("Barre de recherche :")
Spacer(modifier = Modifier.height(8.dp))
HelpText(
"Tapez pour rechercher dans les titres, descriptions et URLs. " +
"La recherche est instantanée et filtre en temps réel."
)
}
// Partage
HelpSection(
title = "Partage depuis d'autres apps",
icon = Icons.Default.Share,
isExpanded = expandedSection == "share",
onToggle = { expandedSection = if (expandedSection == "share") null else "share" }
) {
HelpText(
"ShaarIt s'intègre au système de partage Android pour sauvegarder rapidement des liens depuis n'importe quelle application."
)
Spacer(modifier = Modifier.height(16.dp))
HelpNumberedList(
listOf(
Pair("Partager depuis une app", "Dans votre navigateur ou autre app, appuyez sur 'Partager' et sélectionnez ShaarIt."),
Pair("Compléter les informations", "L'écran d'ajout s'ouvre avec l'URL pré-remplie. Ajoutez un titre, description et tags."),
Pair("Enregistrer", "Le lien est sauvegardé localement et synchronisé avec votre serveur Shaarli.")
)
)
Spacer(modifier = Modifier.height(12.dp))
HelpTip("Le titre et la description sont souvent pré-remplis automatiquement grâce aux métadonnées de la page.")
}
// Widget
HelpSection(
title = "Widget",
icon = Icons.Default.Widgets,
isExpanded = expandedSection == "widget",
onToggle = { expandedSection = if (expandedSection == "widget") null else "widget" }
) {
HelpText(
"Ajoutez le widget ShaarIt à votre écran d'accueil pour un accès rapide à vos derniers liens."
)
Spacer(modifier = Modifier.height(16.dp))
HelpNumberedList(
listOf(
Pair("Ajouter le widget", "Appui long sur l'écran d'accueil > Widgets > ShaarIt."),
Pair("Personnaliser", "Redimensionnez le widget selon vos préférences."),
Pair("Utiliser", "Les liens récents s'affichent directement, cliquez pour les ouvrir.")
)
)
}
// Support
HelpSection(
title = "Support et Contact",
icon = Icons.Default.Help,
isExpanded = expandedSection == "support",
onToggle = { expandedSection = if (expandedSection == "support") null else "support" }
) {
HelpText(
"Besoin d'aide supplémentaire ou souhaitez signaler un bug ?"
)
Spacer(modifier = Modifier.height(16.dp))
HelpInfoCard(
title = "À propos de ShaarIt",
content = "ShaarIt est un client Android pour Shaarli, le gestionnaire de liens personnel open-source. " +
"Cette application fonctionne avec votre propre instance Shaarli auto-hébergée."
)
Spacer(modifier = Modifier.height(12.dp))
HelpText(
"Pour plus d'informations sur Shaarli : github.com/shaarli/Shaarli"
)
}
// Footer
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "ShaarIt v1.0 • © 2026",
style = MaterialTheme.typography.labelSmall,
color = TextMuted.copy(alpha = 0.6f),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
// ============== Help Components ==============
@Composable
private fun HelpHeader() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = CyanPrimary.copy(alpha = 0.15f)
),
border = androidx.compose.foundation.BorderStroke(1.dp, CyanPrimary.copy(alpha = 0.3f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(48.dp)
.background(
color = CyanPrimary.copy(alpha = 0.2f),
shape = MaterialTheme.shapes.medium
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.MenuBook,
contentDescription = null,
tint = CyanPrimary,
modifier = Modifier.size(28.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = "Guide Complet",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
Text(
text = "Tout ce qu'il faut savoir pour maîtriser ShaarIt",
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary
)
}
}
}
}
@Composable
private fun HelpSection(
title: String,
icon: ImageVector,
isExpanded: Boolean,
onToggle: () -> Unit,
content: @Composable ColumnScope.() -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = CardBackground
)
) {
Column {
Surface(
onClick = onToggle,
color = Color.Transparent,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = CyanPrimary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = TextPrimary,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Réduire" else "Développer",
tint = TextMuted
)
}
}
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
) {
Divider(color = TextMuted.copy(alpha = 0.2f))
Spacer(modifier = Modifier.height(12.dp))
content()
}
}
}
}
}
@Composable
private fun HelpSubsection(
title: String,
icon: ImageVector,
description: String
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.Top
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = TealSecondary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = TextPrimary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary
)
}
}
}
@Composable
private fun HelpText(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary
)
}
@Composable
private fun HelpSubtitle(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = CyanLight
)
}
@Composable
private fun HelpFeatureList(items: List<String>) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
items.forEach { item ->
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = SuccessGreen,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = item,
style = MaterialTheme.typography.bodyMedium,
color = TextPrimary
)
}
}
}
}
@Composable
private fun HelpBulletList(items: List<String>) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
items.forEach { item ->
Row(verticalAlignment = Alignment.Top) {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium,
color = CyanPrimary,
modifier = Modifier.width(16.dp)
)
Text(
text = item,
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary
)
}
}
}
}
@Composable
private fun HelpNumberedList(items: List<Pair<String, String>>) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
items.forEachIndexed { index, (title, description) ->
Row(verticalAlignment = Alignment.Top) {
Box(
modifier = Modifier
.size(24.dp)
.background(
color = CyanPrimary.copy(alpha = 0.2f),
shape = MaterialTheme.shapes.small
),
contentAlignment = Alignment.Center
) {
Text(
text = "${index + 1}",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = CyanPrimary
)
}
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = TextPrimary
)
if (description.isNotBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = TextSecondary
)
}
}
}
}
}
}
@Composable
private fun HelpInfoCard(
title: String,
content: String
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = CardBackgroundElevated
)
) {
Column(
modifier = Modifier.padding(12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
tint = TealSecondary,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = TealSecondary
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = content,
style = MaterialTheme.typography.bodySmall,
color = TextSecondary
)
}
}
}
@Composable
private fun HelpTip(text: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(
color = SuccessGreen.copy(alpha = 0.1f),
shape = MaterialTheme.shapes.small
)
.padding(12.dp),
verticalAlignment = Alignment.Top
) {
Icon(
imageVector = Icons.Default.Lightbulb,
contentDescription = null,
tint = SuccessGreen,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = TextPrimary
)
}
}

View File

@ -41,6 +41,7 @@ sealed class Screen(val route: String) {
object Collections : Screen("collections")
object Dashboard : Screen("dashboard")
object Settings : Screen("settings")
object Help : Screen("help")
}
@Composable
@ -123,6 +124,7 @@ fun AppNavGraph(
onNavigateToCollections = { navController.navigate(Screen.Collections.route) },
onNavigateToSettings = { navController.navigate(Screen.Settings.route) },
onNavigateToRandom = { },
onNavigateToHelp = { navController.navigate(Screen.Help.route) },
initialTagFilter = tag,
initialCollectionId = collectionId
)
@ -243,5 +245,16 @@ fun AppNavGraph(
onNavigateToDashboard = { navController.navigate(Screen.Dashboard.route) }
)
}
composable(
route = Screen.Help.route,
deepLinks = listOf(
navDeepLink { uriPattern = "shaarit://help" }
)
) {
com.shaarit.presentation.help.HelpScreen(
onNavigateBack = { navController.popBackStack() }
)
}
}
}

View File

@ -9,13 +9,19 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.AutoAwesome
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.shaarit.data.export.BookmarkImporter
@ -94,8 +100,23 @@ fun SettingsScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// AI Section - Gemini API Key
item {
SettingsSection(title = "Intelligence Artificielle")
}
item {
GeminiApiKeyItem(
apiKey = viewModel.geminiApiKey.collectAsState().value,
onApiKeyChange = { viewModel.updateGeminiApiKey(it) },
onSave = { viewModel.saveGeminiApiKey() },
isConfigured = viewModel.isGeminiApiKeyConfigured()
)
}
// Analytics Section
item {
Spacer(modifier = Modifier.height(16.dp))
SettingsSection(title = "Analytiques")
}
@ -190,6 +211,21 @@ fun SettingsScreen(
)
}
// Maintenance Section
item {
Spacer(modifier = Modifier.height(16.dp))
SettingsSection(title = "Maintenance")
}
item {
SettingsItem(
icon = Icons.Default.Category,
title = "Scanner et Classer",
subtitle = "Détecter les types de contenu et les sites",
onClick = { viewModel.scanAndClassify() }
)
}
// About Section
item {
Spacer(modifier = Modifier.height(16.dp))
@ -392,3 +428,115 @@ sealed class SyncUiStatus {
data class Error(val message: String) : SyncUiStatus()
data class Offline(val pendingChanges: Int) : SyncUiStatus()
}
@Composable
private fun GeminiApiKeyItem(
apiKey: String,
onApiKeyChange: (String) -> Unit,
onSave: () -> Unit,
isConfigured: Boolean
) {
var isExpanded by remember { mutableStateOf(false) }
var showApiKey by remember { mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { isExpanded = !isExpanded }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.AutoAwesome,
contentDescription = null,
tint = if (isConfigured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Clé API Google Gemini",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = if (isConfigured) "✓ Configurée" else "Non configurée",
style = MaterialTheme.typography.bodySmall,
color = if (isConfigured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Réduire" else "Développer",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (isExpanded) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Entrez votre clé API Gemini pour activer l'enrichissement automatique par IA des favoris.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = apiKey,
onValueChange = onApiKeyChange,
modifier = Modifier.fillMaxWidth(),
label = { Text("Clé API") },
placeholder = { Text("AIza...") },
singleLine = true,
visualTransformation = if (showApiKey) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
trailingIcon = {
IconButton(onClick = { showApiKey = !showApiKey }) {
Icon(
imageVector = if (showApiKey) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (showApiKey) "Masquer" else "Afficher"
)
}
}
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(
onClick = onSave
) {
Icon(
Icons.Default.Save,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Sauvegarder")
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Obtenez votre clé sur: ai.google.dev",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View File

@ -3,11 +3,13 @@ package com.shaarit.presentation.settings
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.core.storage.TokenManager
import com.shaarit.data.export.BookmarkExporter
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.domain.usecase.ClassifyBookmarksUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -20,7 +22,9 @@ class SettingsViewModel @Inject constructor(
private val bookmarkExporter: BookmarkExporter,
private val bookmarkImporter: BookmarkImporter,
private val syncManager: SyncManager,
private val linkDao: LinkDao
private val linkDao: LinkDao,
private val classifyBookmarksUseCase: ClassifyBookmarksUseCase,
private val tokenManager: TokenManager
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
@ -29,6 +33,9 @@ class SettingsViewModel @Inject constructor(
private val _syncStatus = MutableStateFlow<SyncUiStatus>(SyncUiStatus.Synced("Jamais"))
val syncStatus: StateFlow<SyncUiStatus> = _syncStatus.asStateFlow()
private val _geminiApiKey = MutableStateFlow(tokenManager.getGeminiApiKey() ?: "")
val geminiApiKey: StateFlow<String> = _geminiApiKey.asStateFlow()
init {
observeSyncStatus()
}
@ -173,6 +180,45 @@ class SettingsViewModel @Inject constructor(
fun clearImportResult() {
_uiState.value = _uiState.value.copy(importResult = null)
}
fun scanAndClassify() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, message = "Scan en cours...")
classifyBookmarksUseCase()
.onSuccess { count ->
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "Scan terminé : $count liens mis à jour"
)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "Erreur lors du scan : ${error.message}"
)
}
}
}
fun updateGeminiApiKey(apiKey: String) {
_geminiApiKey.value = apiKey
}
fun saveGeminiApiKey() {
val apiKey = _geminiApiKey.value.trim()
if (apiKey.isNotBlank()) {
tokenManager.saveGeminiApiKey(apiKey)
_uiState.value = _uiState.value.copy(message = "Clé API Gemini sauvegardée")
} else {
tokenManager.clearGeminiApiKey()
_uiState.value = _uiState.value.copy(message = "Clé API Gemini supprimée")
}
}
fun isGeminiApiKeyConfigured(): Boolean {
return !tokenManager.getGeminiApiKey().isNullOrBlank()
}
}
data class SettingsUiState(

View File

@ -348,7 +348,10 @@ fun FloatingMarkdownToolbar(
// Utiliser les insets standard pour un positionnement robuste
// L'union de IME et NavigationBars assure que la toolbar est toujours au-dessus du plus haut des deux
val insets = WindowInsets.ime.union(WindowInsets.navigationBars)
val density = LocalDensity.current
val imeBottom = WindowInsets.ime.getBottom(density)
val navBottom = WindowInsets.navigationBars.getBottom(density)
val bottomPadding = with(density) { maxOf(imeBottom, navBottom).toDp() }
AnimatedVisibility(
visible = visible && editorState.isFocused,
@ -360,7 +363,7 @@ fun FloatingMarkdownToolbar(
targetOffsetY = { it },
animationSpec = tween(durationMillis = 150)
) + fadeOut(animationSpec = tween(durationMillis = 100)),
modifier = modifier.windowInsetsPadding(insets)
modifier = modifier.padding(bottom = bottomPadding)
) {
Surface(
color = CardBackground,

View File

@ -43,6 +43,10 @@ val ErrorRed = Color(0xFFEF4444)
val GradientStart = Color(0xFF0EA5E9)
val GradientEnd = Color(0xFF00D4AA)
// AI/Magic Colors
val Purple = Color(0xFFA855F7)
val PurpleLight = Color(0xFFC084FC)
private val DarkColorScheme =
darkColorScheme(
primary = CyanPrimary,