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:
parent
4021aacc1d
commit
f88b7ffad3
@ -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)
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -132,6 +132,65 @@ constructor(
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
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) ======
|
||||
|
||||
|
||||
@ -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")
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<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()
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
937
app/src/main/java/com/shaarit/presentation/help/HelpScreen.kt
Normal file
937
app/src/main/java/com/shaarit/presentation/help/HelpScreen.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -189,6 +210,21 @@ fun SettingsScreen(
|
||||
onSyncClick = { viewModel.triggerManualSync() }
|
||||
)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user