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
@ -145,6 +145,9 @@ dependencies {
|
|||||||
// JSoup for HTML parsing (metadata extraction)
|
// JSoup for HTML parsing (metadata extraction)
|
||||||
implementation(libs.jsoup)
|
implementation(libs.jsoup)
|
||||||
|
|
||||||
|
// Google Gemini AI SDK
|
||||||
|
implementation("com.google.ai.client.generativeai:generativeai:0.9.0")
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@ -33,7 +33,8 @@
|
|||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/Theme.ShaarIt.Splash">
|
android:theme="@style/Theme.ShaarIt.Splash"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
package com.shaarit.core.di
|
package com.shaarit.core.di
|
||||||
|
|
||||||
import com.shaarit.data.repository.AuthRepositoryImpl
|
import com.shaarit.data.repository.AuthRepositoryImpl
|
||||||
|
import com.shaarit.data.repository.GeminiRepositoryImpl
|
||||||
import com.shaarit.data.repository.LinkRepositoryImpl
|
import com.shaarit.data.repository.LinkRepositoryImpl
|
||||||
import com.shaarit.domain.repository.AuthRepository
|
import com.shaarit.domain.repository.AuthRepository
|
||||||
|
import com.shaarit.domain.repository.GeminiRepository
|
||||||
import com.shaarit.domain.repository.LinkRepository
|
import com.shaarit.domain.repository.LinkRepository
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
@ -17,4 +19,6 @@ abstract class RepositoryModule {
|
|||||||
@Binds @Singleton abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
|
@Binds @Singleton abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
|
||||||
|
|
||||||
@Binds @Singleton abstract fun bindLinkRepository(impl: LinkRepositoryImpl): LinkRepository
|
@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 saveCollectionsConfigBookmarkId(id: Int)
|
||||||
fun getCollectionsConfigBookmarkId(): Int?
|
fun getCollectionsConfigBookmarkId(): Int?
|
||||||
fun clearCollectionsConfigBookmarkId()
|
fun clearCollectionsConfigBookmarkId()
|
||||||
|
|
||||||
|
fun saveGeminiApiKey(apiKey: String)
|
||||||
|
fun getGeminiApiKey(): String?
|
||||||
|
fun clearGeminiApiKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
@ -112,6 +116,18 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte
|
|||||||
sharedPreferences.edit().remove(KEY_COLLECTIONS_BOOKMARK_ID).apply()
|
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 {
|
companion object {
|
||||||
private const val KEY_TOKEN = "jwt_token"
|
private const val KEY_TOKEN = "jwt_token"
|
||||||
private const val KEY_BASE_URL = "base_url"
|
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_DIRTY = "collections_config_dirty"
|
||||||
private const val KEY_COLLECTIONS_BOOKMARK_ID = "collections_config_bookmark_id"
|
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
|
return@forEach
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val contentType = try {
|
||||||
|
ContentType.valueOf(link.contentType)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ContentType.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
val entity = LinkEntity(
|
val entity = LinkEntity(
|
||||||
id = 0,
|
id = 0,
|
||||||
url = link.url,
|
url = link.url,
|
||||||
@ -125,7 +131,7 @@ class BookmarkImporter @Inject constructor(
|
|||||||
siteName = link.siteName,
|
siteName = link.siteName,
|
||||||
thumbnailUrl = link.thumbnailUrl,
|
thumbnailUrl = link.thumbnailUrl,
|
||||||
readingTimeMinutes = link.readingTimeMinutes,
|
readingTimeMinutes = link.readingTimeMinutes,
|
||||||
contentType = ContentType.valueOf(link.contentType),
|
contentType = contentType,
|
||||||
syncStatus = SyncStatus.PENDING_CREATE
|
syncStatus = SyncStatus.PENDING_CREATE
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -194,10 +200,35 @@ class BookmarkImporter @Inject constructor(
|
|||||||
private fun detectContentType(url: String): ContentType {
|
private fun detectContentType(url: String): ContentType {
|
||||||
return when {
|
return when {
|
||||||
url.contains("youtube.com") || url.contains("youtu.be") ||
|
url.contains("youtube.com") || url.contains("youtu.be") ||
|
||||||
url.contains("vimeo.com") -> ContentType.VIDEO
|
url.contains("vimeo.com") || url.contains("dailymotion.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("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.matches(Regex(".*\\.(jpg|jpeg|png|gif|webp)$", RegexOption.IGNORE_CASE)) -> ContentType.IMAGE
|
||||||
|
|
||||||
|
url.endsWith(".pdf", ignoreCase = true) -> ContentType.PDF
|
||||||
|
|
||||||
else -> ContentType.ARTICLE
|
else -> ContentType.ARTICLE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import androidx.room.RawQuery
|
|||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
import androidx.room.Update
|
import androidx.room.Update
|
||||||
import androidx.sqlite.db.SupportSQLiteQuery
|
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.LinkEntity
|
||||||
import com.shaarit.data.local.entity.LinkFtsEntity
|
import com.shaarit.data.local.entity.LinkFtsEntity
|
||||||
import com.shaarit.data.local.entity.SyncStatus
|
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")
|
@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())
|
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")
|
@Query("DELETE FROM links WHERE id = :id")
|
||||||
suspend fun deleteLink(id: Int)
|
suspend fun deleteLink(id: Int)
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,7 @@ import com.shaarit.data.local.entity.TagEntity
|
|||||||
CollectionLinkCrossRef::class
|
CollectionLinkCrossRef::class
|
||||||
],
|
],
|
||||||
version = 1,
|
version = 1,
|
||||||
exportSchema = true
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
abstract class ShaarliDatabase : RoomDatabase() {
|
abstract class ShaarliDatabase : RoomDatabase() {
|
||||||
|
|||||||
@ -97,7 +97,9 @@ enum class ContentType {
|
|||||||
DOCUMENT, // Google Docs, Notion, etc.
|
DOCUMENT, // Google Docs, Notion, etc.
|
||||||
SOCIAL, // Twitter, Mastodon, etc.
|
SOCIAL, // Twitter, Mastodon, etc.
|
||||||
SHOPPING, // Amazon, 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
|
ogType == "article" || doc.select("article").isNotEmpty() -> ContentType.ARTICLE
|
||||||
url.contains(Regex("github\\.com|gitlab\\.com|bitbucket")) -> ContentType.REPOSITORY
|
url.contains(Regex("github\\.com|gitlab\\.com|bitbucket")) -> ContentType.REPOSITORY
|
||||||
url.contains(Regex("docs\\.google\\.com|notion\\.so|confluence")) -> ContentType.DOCUMENT
|
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("amazon|ebay|shopify")) -> ContentType.SHOPPING
|
||||||
url.contains(Regex("substack|revue|mailchimp")) -> ContentType.NEWSLETTER
|
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
|
url.endsWith(".pdf") -> ContentType.PDF
|
||||||
else -> ContentType.UNKNOWN
|
else -> ContentType.UNKNOWN
|
||||||
}
|
}
|
||||||
@ -191,10 +193,13 @@ class LinkMetadataExtractor @Inject constructor() {
|
|||||||
private fun detectContentTypeFromUrl(url: String): ContentType {
|
private fun detectContentTypeFromUrl(url: String): ContentType {
|
||||||
return when {
|
return when {
|
||||||
url.contains(Regex("youtube\\.com|youtu\\.be|vimeo|dailymotion")) -> ContentType.VIDEO
|
url.contains(Regex("youtube\\.com|youtu\\.be|vimeo|dailymotion")) -> ContentType.VIDEO
|
||||||
url.contains(Regex("github\\.com|gitlab")) -> ContentType.REPOSITORY
|
url.contains(Regex("github\\.com|gitlab|bitbucket")) -> ContentType.REPOSITORY
|
||||||
url.contains(Regex("docs\\.google|notion\\.so")) -> ContentType.DOCUMENT
|
url.contains(Regex("docs\\.google|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")) -> ContentType.SHOPPING
|
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
|
url.endsWith(".pdf") -> ContentType.PDF
|
||||||
else -> ContentType.UNKNOWN
|
else -> ContentType.UNKNOWN
|
||||||
}
|
}
|
||||||
@ -243,7 +248,7 @@ class LinkMetadataExtractor @Inject constructor() {
|
|||||||
path.trim('/').split('/').lastOrNull()
|
path.trim('/').split('/').lastOrNull()
|
||||||
?.replace('-', ' ')
|
?.replace('-', ' ')
|
||||||
?.replace('_', ' ')
|
?.replace('_', ' ')
|
||||||
?.capitalize()
|
?.replaceFirstChar { if (it.isLowerCase()) it.titlecase(java.util.Locale.getDefault()) else it.toString() }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
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}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -133,6 +133,65 @@ constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getAllLinks(): Result<List<ShaarliLink>> {
|
||||||
|
return try {
|
||||||
|
val localLinks = linkDao.getAllLinksForStats()
|
||||||
|
Result.success(localLinks.map { it.toDomainModel() })
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateLinkClassification(
|
||||||
|
id: Int,
|
||||||
|
contentType: String?,
|
||||||
|
siteName: String?,
|
||||||
|
tagsToAdd: List<String>?
|
||||||
|
): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
val type = try {
|
||||||
|
if (contentType != null) ContentType.valueOf(contentType) else ContentType.UNKNOWN
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ContentType.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update classification
|
||||||
|
linkDao.updateLinkClassification(id, type, siteName)
|
||||||
|
|
||||||
|
// Add tags if provided
|
||||||
|
if (!tagsToAdd.isNullOrEmpty()) {
|
||||||
|
val existingLink = linkDao.getLinkById(id)
|
||||||
|
if (existingLink != null) {
|
||||||
|
val currentTags = existingLink.tags.toMutableList()
|
||||||
|
var tagsChanged = false
|
||||||
|
|
||||||
|
for (tag in tagsToAdd) {
|
||||||
|
if (tag !in currentTags) {
|
||||||
|
currentTags.add(tag)
|
||||||
|
tagsChanged = true
|
||||||
|
|
||||||
|
// Update tag counts
|
||||||
|
val existingTag = tagDao.getTagByName(tag)
|
||||||
|
if (existingTag != null) {
|
||||||
|
tagDao.incrementOccurrences(tag)
|
||||||
|
} else {
|
||||||
|
tagDao.insertTag(TagEntity(tag, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagsChanged) {
|
||||||
|
linkDao.updateLinkTags(id, currentTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ====== Écriture (avec file d'attente de sync) ======
|
// ====== Écriture (avec file d'attente de sync) ======
|
||||||
|
|
||||||
override suspend fun addLink(
|
override suspend fun addLink(
|
||||||
|
|||||||
@ -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 getTags(): Result<List<ShaarliTag>>
|
||||||
|
|
||||||
suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>>
|
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
|
package com.shaarit.presentation.add
|
||||||
|
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@ -15,6 +16,7 @@ import androidx.compose.foundation.text.KeyboardOptions
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@ -33,6 +35,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.shaarit.ui.components.*
|
import com.shaarit.ui.components.*
|
||||||
import com.shaarit.ui.theme.*
|
import com.shaarit.ui.theme.*
|
||||||
|
import com.shaarit.ui.theme.Purple
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
@ -62,6 +65,8 @@ fun AddLinkScreen(
|
|||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val aiEnrichmentState by viewModel.aiEnrichmentState.collectAsState()
|
||||||
|
|
||||||
// State pour l'éditeur Markdown avec barre d'outils flottante
|
// State pour l'éditeur Markdown avec barre d'outils flottante
|
||||||
val markdownEditorState = rememberMarkdownEditorState()
|
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
|
// Conflict Dialog
|
||||||
if (uiState is AddLinkUiState.Conflict) {
|
if (uiState is AddLinkUiState.Conflict) {
|
||||||
val conflict = uiState as AddLinkUiState.Conflict
|
val conflict = uiState as AddLinkUiState.Conflict
|
||||||
@ -208,11 +226,16 @@ fun AddLinkScreen(
|
|||||||
CompactFieldCard(
|
CompactFieldCard(
|
||||||
icon = Icons.Default.Link,
|
icon = Icons.Default.Link,
|
||||||
label = "URL"
|
label = "URL"
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = url,
|
value = url,
|
||||||
onValueChange = { viewModel.url.value = it },
|
onValueChange = { viewModel.url.value = it },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.weight(1f),
|
||||||
placeholder = { Text("https://example.com", color = TextMuted) },
|
placeholder = { Text("https://example.com", color = TextMuted) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
@ -230,6 +253,14 @@ fun AddLinkScreen(
|
|||||||
textStyle = MaterialTheme.typography.bodyMedium
|
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
|
// Thumbnail preview
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = extractedThumbnail != null || contentType != null,
|
visible = extractedThumbnail != null || contentType != null,
|
||||||
@ -247,6 +278,13 @@ fun AddLinkScreen(
|
|||||||
"ARTICLE" -> Icons.Default.Article
|
"ARTICLE" -> Icons.Default.Article
|
||||||
"PODCAST" -> Icons.Default.Headphones
|
"PODCAST" -> Icons.Default.Headphones
|
||||||
"REPOSITORY" -> Icons.Default.Code
|
"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
|
else -> Icons.Default.Web
|
||||||
},
|
},
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@ -734,3 +772,54 @@ private fun compactTextFieldColors() = OutlinedTextFieldDefaults.colors(
|
|||||||
unfocusedContainerColor = CardBackground.copy(alpha = 0.2f)
|
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.shaarit.data.metadata.LinkMetadataExtractor
|
import com.shaarit.data.metadata.LinkMetadataExtractor
|
||||||
|
import com.shaarit.domain.model.AiEnrichmentResult
|
||||||
import com.shaarit.domain.model.ShaarliTag
|
import com.shaarit.domain.model.ShaarliTag
|
||||||
import com.shaarit.domain.repository.AddLinkResult
|
import com.shaarit.domain.repository.AddLinkResult
|
||||||
import com.shaarit.domain.repository.LinkRepository
|
import com.shaarit.domain.repository.LinkRepository
|
||||||
|
import com.shaarit.domain.usecase.AnalyzeUrlWithAiUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
@ -24,6 +28,7 @@ class AddLinkViewModel
|
|||||||
constructor(
|
constructor(
|
||||||
private val linkRepository: LinkRepository,
|
private val linkRepository: LinkRepository,
|
||||||
private val metadataExtractor: LinkMetadataExtractor,
|
private val metadataExtractor: LinkMetadataExtractor,
|
||||||
|
private val analyzeUrlWithAiUseCase: AnalyzeUrlWithAiUseCase,
|
||||||
savedStateHandle: SavedStateHandle
|
savedStateHandle: SavedStateHandle
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@ -69,6 +74,12 @@ constructor(
|
|||||||
private val _tagSuggestions = MutableStateFlow<List<ShaarliTag>>(emptyList())
|
private val _tagSuggestions = MutableStateFlow<List<ShaarliTag>>(emptyList())
|
||||||
val tagSuggestions = _tagSuggestions.asStateFlow()
|
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
|
// For conflict handling
|
||||||
private var conflictLinkId: Int? = null
|
private var conflictLinkId: Int? = null
|
||||||
|
|
||||||
@ -211,6 +222,56 @@ constructor(
|
|||||||
addTag(_newTagInput.value)
|
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) {
|
fun removeTag(tag: String) {
|
||||||
_selectedTags.value = _selectedTags.value - tag
|
_selectedTags.value = _selectedTags.value - tag
|
||||||
}
|
}
|
||||||
@ -347,3 +408,9 @@ enum class ContentType {
|
|||||||
BOOKMARK,
|
BOOKMARK,
|
||||||
NOTE
|
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.SOCIAL -> Triple(Icons.Default.Share, "Social", Color(0xFF03A9F4))
|
||||||
ContentType.SHOPPING -> Triple(Icons.Default.ShoppingCart, "Shopping", Color(0xFF2196F3))
|
ContentType.SHOPPING -> Triple(Icons.Default.ShoppingCart, "Shopping", Color(0xFF2196F3))
|
||||||
ContentType.NEWSLETTER -> Triple(Icons.Default.Email, "Newsletter", Color(0xFF9C27B0))
|
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))
|
ContentType.UNKNOWN -> Triple(Icons.Default.Link, "Autre", Color(0xFF9E9E9E))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.shaarit.presentation.edit
|
package com.shaarit.presentation.edit
|
||||||
|
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@ -15,6 +16,7 @@ import androidx.compose.foundation.text.KeyboardOptions
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@ -33,6 +35,7 @@ import coil.compose.AsyncImage
|
|||||||
import com.shaarit.presentation.add.ContentType
|
import com.shaarit.presentation.add.ContentType
|
||||||
import com.shaarit.ui.components.*
|
import com.shaarit.ui.components.*
|
||||||
import com.shaarit.ui.theme.*
|
import com.shaarit.ui.theme.*
|
||||||
|
import com.shaarit.ui.theme.Purple
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
@ -58,6 +61,8 @@ fun EditLinkScreen(
|
|||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val aiEnrichmentState by viewModel.aiEnrichmentState.collectAsState()
|
||||||
|
|
||||||
// State pour l'éditeur Markdown avec barre d'outils flottante
|
// State pour l'éditeur Markdown avec barre d'outils flottante
|
||||||
val markdownEditorState = rememberMarkdownEditorState()
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@ -178,11 +196,16 @@ fun EditLinkScreen(
|
|||||||
CompactFieldCard(
|
CompactFieldCard(
|
||||||
icon = Icons.Default.Link,
|
icon = Icons.Default.Link,
|
||||||
label = "URL"
|
label = "URL"
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = url,
|
value = url,
|
||||||
onValueChange = { viewModel.url.value = it },
|
onValueChange = { viewModel.url.value = it },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.weight(1f),
|
||||||
placeholder = { Text("https://example.com", color = TextMuted) },
|
placeholder = { Text("https://example.com", color = TextMuted) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
@ -190,6 +213,14 @@ fun EditLinkScreen(
|
|||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
textStyle = MaterialTheme.typography.bodyMedium
|
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)
|
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.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.shaarit.domain.model.AiEnrichmentResult
|
||||||
import com.shaarit.domain.model.ShaarliTag
|
import com.shaarit.domain.model.ShaarliTag
|
||||||
import com.shaarit.domain.repository.LinkRepository
|
import com.shaarit.domain.repository.LinkRepository
|
||||||
|
import com.shaarit.domain.usecase.AnalyzeUrlWithAiUseCase
|
||||||
import com.shaarit.presentation.add.ContentType
|
import com.shaarit.presentation.add.ContentType
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@ -17,6 +21,7 @@ class EditLinkViewModel
|
|||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val linkRepository: LinkRepository,
|
private val linkRepository: LinkRepository,
|
||||||
|
private val analyzeUrlWithAiUseCase: AnalyzeUrlWithAiUseCase,
|
||||||
savedStateHandle: SavedStateHandle
|
savedStateHandle: SavedStateHandle
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@ -49,6 +54,12 @@ constructor(
|
|||||||
private val _tagSuggestions = MutableStateFlow<List<ShaarliTag>>(emptyList())
|
private val _tagSuggestions = MutableStateFlow<List<ShaarliTag>>(emptyList())
|
||||||
val tagSuggestions = _tagSuggestions.asStateFlow()
|
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 {
|
init {
|
||||||
loadLink()
|
loadLink()
|
||||||
loadAvailableTags()
|
loadAvailableTags()
|
||||||
@ -145,6 +156,56 @@ constructor(
|
|||||||
addTag(_newTagInput.value)
|
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) {
|
fun removeTag(tag: String) {
|
||||||
_selectedTags.value = _selectedTags.value - tag
|
_selectedTags.value = _selectedTags.value - tag
|
||||||
}
|
}
|
||||||
@ -199,3 +260,9 @@ sealed class EditLinkUiState {
|
|||||||
object Success : EditLinkUiState()
|
object Success : EditLinkUiState()
|
||||||
data class Error(val message: String) : 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 = {},
|
onNavigateToCollections: () -> Unit = {},
|
||||||
onNavigateToSettings: () -> Unit = {},
|
onNavigateToSettings: () -> Unit = {},
|
||||||
onNavigateToRandom: () -> Unit = {},
|
onNavigateToRandom: () -> Unit = {},
|
||||||
|
onNavigateToHelp: () -> Unit = {},
|
||||||
initialTagFilter: String? = null,
|
initialTagFilter: String? = null,
|
||||||
initialCollectionId: Long? = null,
|
initialCollectionId: Long? = null,
|
||||||
viewModel: FeedViewModel = hiltViewModel()
|
viewModel: FeedViewModel = hiltViewModel()
|
||||||
@ -295,7 +296,6 @@ fun FeedScreen(
|
|||||||
var selectionMode by remember { mutableStateOf(false) }
|
var selectionMode by remember { mutableStateOf(false) }
|
||||||
var selectedIds by remember { mutableStateOf(setOf<Int>()) }
|
var selectedIds by remember { mutableStateOf(setOf<Int>()) }
|
||||||
var showAddToCollectionDialog by remember { mutableStateOf(false) }
|
var showAddToCollectionDialog by remember { mutableStateOf(false) }
|
||||||
var showHelpDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// États des accordéons du drawer
|
// États des accordéons du drawer
|
||||||
var mainMenuExpanded by remember { mutableStateOf(true) }
|
var mainMenuExpanded by remember { mutableStateOf(true) }
|
||||||
@ -430,7 +430,7 @@ fun FeedScreen(
|
|||||||
label = "Aide",
|
label = "Aide",
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
showHelpDialog = true
|
onNavigateToHelp()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -519,6 +519,51 @@ fun FeedScreen(
|
|||||||
viewModel.setTagFilter("podcast")
|
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) {
|
if (collectionId != null) {
|
||||||
|
val collectionName = remember(collectionId, collections) {
|
||||||
|
collections.find { it.id == collectionId }?.name ?: "Collection #$collectionId"
|
||||||
|
}
|
||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = { viewModel.clearCollectionFilter() },
|
onClick = { viewModel.clearCollectionFilter() },
|
||||||
label = { Text("Collection #$collectionId") },
|
label = { Text(collectionName) },
|
||||||
colors = AssistChipDefaults.assistChipColors(
|
colors = AssistChipDefaults.assistChipColors(
|
||||||
containerColor = CardBackground,
|
containerColor = CardBackground,
|
||||||
labelColor = CyanPrimary
|
labelColor = CyanPrimary
|
||||||
@ -1208,7 +1256,10 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { viewModel.clearTagFilter() },
|
onClick = {
|
||||||
|
viewModel.clearTagFilter()
|
||||||
|
viewModel.clearCollectionFilter()
|
||||||
|
},
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(32.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@ -1521,6 +1572,7 @@ fun FeedScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedLink != null) {
|
if (selectedLink != null) {
|
||||||
LinkDetailsDialog(
|
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 Collections : Screen("collections")
|
||||||
object Dashboard : Screen("dashboard")
|
object Dashboard : Screen("dashboard")
|
||||||
object Settings : Screen("settings")
|
object Settings : Screen("settings")
|
||||||
|
object Help : Screen("help")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -123,6 +124,7 @@ fun AppNavGraph(
|
|||||||
onNavigateToCollections = { navController.navigate(Screen.Collections.route) },
|
onNavigateToCollections = { navController.navigate(Screen.Collections.route) },
|
||||||
onNavigateToSettings = { navController.navigate(Screen.Settings.route) },
|
onNavigateToSettings = { navController.navigate(Screen.Settings.route) },
|
||||||
onNavigateToRandom = { },
|
onNavigateToRandom = { },
|
||||||
|
onNavigateToHelp = { navController.navigate(Screen.Help.route) },
|
||||||
initialTagFilter = tag,
|
initialTagFilter = tag,
|
||||||
initialCollectionId = collectionId
|
initialCollectionId = collectionId
|
||||||
)
|
)
|
||||||
@ -243,5 +245,16 @@ fun AppNavGraph(
|
|||||||
onNavigateToDashboard = { navController.navigate(Screen.Dashboard.route) }
|
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.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.shaarit.data.export.BookmarkImporter
|
import com.shaarit.data.export.BookmarkImporter
|
||||||
@ -94,8 +100,23 @@ fun SettingsScreen(
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.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
|
// Analytics Section
|
||||||
item {
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
SettingsSection(title = "Analytiques")
|
SettingsSection(title = "Analytiques")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,6 +211,21 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Maintenance Section
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
SettingsSection(title = "Maintenance")
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SettingsItem(
|
||||||
|
icon = Icons.Default.Category,
|
||||||
|
title = "Scanner et Classer",
|
||||||
|
subtitle = "Détecter les types de contenu et les sites",
|
||||||
|
onClick = { viewModel.scanAndClassify() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// About Section
|
// About Section
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@ -392,3 +428,115 @@ sealed class SyncUiStatus {
|
|||||||
data class Error(val message: String) : SyncUiStatus()
|
data class Error(val message: String) : SyncUiStatus()
|
||||||
data class Offline(val pendingChanges: Int) : 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 android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.shaarit.core.storage.TokenManager
|
||||||
import com.shaarit.data.export.BookmarkExporter
|
import com.shaarit.data.export.BookmarkExporter
|
||||||
import com.shaarit.data.export.BookmarkImporter
|
import com.shaarit.data.export.BookmarkImporter
|
||||||
import com.shaarit.data.local.dao.LinkDao
|
import com.shaarit.data.local.dao.LinkDao
|
||||||
import com.shaarit.data.sync.SyncManager
|
import com.shaarit.data.sync.SyncManager
|
||||||
import com.shaarit.data.sync.SyncState
|
import com.shaarit.data.sync.SyncState
|
||||||
|
import com.shaarit.domain.usecase.ClassifyBookmarksUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -20,7 +22,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
private val bookmarkExporter: BookmarkExporter,
|
private val bookmarkExporter: BookmarkExporter,
|
||||||
private val bookmarkImporter: BookmarkImporter,
|
private val bookmarkImporter: BookmarkImporter,
|
||||||
private val syncManager: SyncManager,
|
private val syncManager: SyncManager,
|
||||||
private val linkDao: LinkDao
|
private val linkDao: LinkDao,
|
||||||
|
private val classifyBookmarksUseCase: ClassifyBookmarksUseCase,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||||
@ -29,6 +33,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
private val _syncStatus = MutableStateFlow<SyncUiStatus>(SyncUiStatus.Synced("Jamais"))
|
private val _syncStatus = MutableStateFlow<SyncUiStatus>(SyncUiStatus.Synced("Jamais"))
|
||||||
val syncStatus: StateFlow<SyncUiStatus> = _syncStatus.asStateFlow()
|
val syncStatus: StateFlow<SyncUiStatus> = _syncStatus.asStateFlow()
|
||||||
|
|
||||||
|
private val _geminiApiKey = MutableStateFlow(tokenManager.getGeminiApiKey() ?: "")
|
||||||
|
val geminiApiKey: StateFlow<String> = _geminiApiKey.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
observeSyncStatus()
|
observeSyncStatus()
|
||||||
}
|
}
|
||||||
@ -173,6 +180,45 @@ class SettingsViewModel @Inject constructor(
|
|||||||
fun clearImportResult() {
|
fun clearImportResult() {
|
||||||
_uiState.value = _uiState.value.copy(importResult = null)
|
_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(
|
data class SettingsUiState(
|
||||||
|
|||||||
@ -348,7 +348,10 @@ fun FloatingMarkdownToolbar(
|
|||||||
|
|
||||||
// Utiliser les insets standard pour un positionnement robuste
|
// 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
|
// 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(
|
AnimatedVisibility(
|
||||||
visible = visible && editorState.isFocused,
|
visible = visible && editorState.isFocused,
|
||||||
@ -360,7 +363,7 @@ fun FloatingMarkdownToolbar(
|
|||||||
targetOffsetY = { it },
|
targetOffsetY = { it },
|
||||||
animationSpec = tween(durationMillis = 150)
|
animationSpec = tween(durationMillis = 150)
|
||||||
) + fadeOut(animationSpec = tween(durationMillis = 100)),
|
) + fadeOut(animationSpec = tween(durationMillis = 100)),
|
||||||
modifier = modifier.windowInsetsPadding(insets)
|
modifier = modifier.padding(bottom = bottomPadding)
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = CardBackground,
|
color = CardBackground,
|
||||||
|
|||||||
@ -43,6 +43,10 @@ val ErrorRed = Color(0xFFEF4444)
|
|||||||
val GradientStart = Color(0xFF0EA5E9)
|
val GradientStart = Color(0xFF0EA5E9)
|
||||||
val GradientEnd = Color(0xFF00D4AA)
|
val GradientEnd = Color(0xFF00D4AA)
|
||||||
|
|
||||||
|
// AI/Magic Colors
|
||||||
|
val Purple = Color(0xFFA855F7)
|
||||||
|
val PurpleLight = Color(0xFFC084FC)
|
||||||
|
|
||||||
private val DarkColorScheme =
|
private val DarkColorScheme =
|
||||||
darkColorScheme(
|
darkColorScheme(
|
||||||
primary = CyanPrimary,
|
primary = CyanPrimary,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user