Bruno Charest f88b7ffad3 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
2026-01-31 11:19:41 -05:00

269 lines
9.1 KiB
Kotlin

package com.shaarit.presentation.edit
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.domain.model.AiEnrichmentResult
import com.shaarit.domain.model.ShaarliTag
import com.shaarit.domain.repository.LinkRepository
import com.shaarit.domain.usecase.AnalyzeUrlWithAiUseCase
import com.shaarit.presentation.add.ContentType
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class EditLinkViewModel
@Inject
constructor(
private val linkRepository: LinkRepository,
private val analyzeUrlWithAiUseCase: AnalyzeUrlWithAiUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val linkId: Int = savedStateHandle["linkId"] ?: -1
private val _uiState = MutableStateFlow<EditLinkUiState>(EditLinkUiState.Loading)
val uiState = _uiState.asStateFlow()
var url = MutableStateFlow("")
var title = MutableStateFlow("")
var description = MutableStateFlow("")
var isPrivate = MutableStateFlow(false)
// Content type - détecté automatiquement ou choisi par l'utilisateur
private val _contentType = MutableStateFlow(ContentType.BOOKMARK)
val contentType = _contentType.asStateFlow()
// Tags du lien original pour détecter si c'est une note
private var originalTags: List<String> = emptyList()
private val _selectedTags = MutableStateFlow<List<String>>(emptyList())
val selectedTags = _selectedTags.asStateFlow()
private val _newTagInput = MutableStateFlow("")
val newTagInput = _newTagInput.asStateFlow()
private val _availableTags = MutableStateFlow<List<ShaarliTag>>(emptyList())
val availableTags = _availableTags.asStateFlow()
private val _tagSuggestions = MutableStateFlow<List<ShaarliTag>>(emptyList())
val tagSuggestions = _tagSuggestions.asStateFlow()
private val _aiEnrichmentState = MutableStateFlow<AiEnrichmentState>(AiEnrichmentState.Idle)
val aiEnrichmentState = _aiEnrichmentState.asStateFlow()
private val _aiErrorMessage = MutableSharedFlow<String>()
val aiErrorMessage = _aiErrorMessage.asSharedFlow()
init {
loadLink()
loadAvailableTags()
}
private fun loadLink() {
viewModelScope.launch {
_uiState.value = EditLinkUiState.Loading
linkRepository.getLink(linkId).fold(
onSuccess = { link ->
url.value = link.url
title.value = link.title
description.value = link.description
isPrivate.value = link.isPrivate
_selectedTags.value = link.tags
originalTags = link.tags
// Détecter si c'est une note
val isNote = link.tags.contains("note") || link.url.startsWith("note://")
_contentType.value = if (isNote) ContentType.NOTE else ContentType.BOOKMARK
_uiState.value = EditLinkUiState.Loaded
},
onFailure = { error ->
_uiState.value = EditLinkUiState.Error(error.message ?: "Failed to load link")
}
)
}
}
private fun loadAvailableTags() {
viewModelScope.launch {
linkRepository
.getTags()
.fold(
onSuccess = { tags ->
_availableTags.value = tags.sortedByDescending { it.occurrences }
},
onFailure = {
// Silently fail - tags are optional
}
)
}
}
/**
* Change le type de contenu (Bookmark ou Note)
*/
fun setContentType(type: ContentType) {
_contentType.value = type
// Auto-add "note" tag when Note type is selected
if (type == ContentType.NOTE && "note" !in _selectedTags.value) {
addTag("note")
} else if (type == ContentType.BOOKMARK && "note" in _selectedTags.value) {
// Remove "note" tag when switching back to Bookmark
removeTag("note")
}
}
fun onNewTagInputChanged(input: String) {
_newTagInput.value = input
updateTagSuggestions(input)
}
private fun updateTagSuggestions(query: String) {
if (query.isBlank()) {
_tagSuggestions.value = emptyList()
return
}
val queryLower = query.lowercase()
_tagSuggestions.value =
_availableTags
.value
.filter {
it.name.lowercase().contains(queryLower) &&
it.name !in _selectedTags.value
}
.take(10)
}
fun addTag(tag: String) {
val cleanTag = tag.trim().lowercase()
if (cleanTag.isNotBlank() && cleanTag !in _selectedTags.value) {
_selectedTags.value = _selectedTags.value + cleanTag
_newTagInput.value = ""
_tagSuggestions.value = emptyList()
}
}
fun addNewTag() {
addTag(_newTagInput.value)
}
fun analyzeUrlWithAi() {
val currentUrl = url.value
if (currentUrl.isBlank()) {
viewModelScope.launch {
_aiErrorMessage.emit("Veuillez d'abord entrer une URL")
}
return
}
if (!analyzeUrlWithAiUseCase.isApiKeyConfigured()) {
viewModelScope.launch {
_aiErrorMessage.emit("Clé API Gemini non configurée. Allez dans Paramètres.")
}
return
}
viewModelScope.launch {
_aiEnrichmentState.value = AiEnrichmentState.Loading
analyzeUrlWithAiUseCase(currentUrl)
.onSuccess { result ->
applyAiEnrichment(result)
_aiEnrichmentState.value = AiEnrichmentState.Success
}
.onFailure { error ->
_aiEnrichmentState.value = AiEnrichmentState.Idle
_aiErrorMessage.emit(error.message ?: "Erreur lors de l'analyse IA")
}
}
}
private fun applyAiEnrichment(result: AiEnrichmentResult) {
title.value = result.title
description.value = result.description
// Add AI-generated tags to existing tags (without duplicates)
val currentTags = _selectedTags.value.toMutableSet()
result.tags.forEach { tag ->
val cleanTag = tag.trim().lowercase()
if (cleanTag.isNotBlank()) {
currentTags.add(cleanTag)
}
}
_selectedTags.value = currentTags.toList()
}
fun resetAiEnrichmentState() {
_aiEnrichmentState.value = AiEnrichmentState.Idle
}
fun removeTag(tag: String) {
_selectedTags.value = _selectedTags.value - tag
}
fun updateLink() {
viewModelScope.launch {
_uiState.value = EditLinkUiState.Saving
val currentUrl = url.value
val currentTitle = title.value
// Validation based on content type
when (_contentType.value) {
ContentType.BOOKMARK -> {
if (currentUrl.isBlank()) {
_uiState.value = EditLinkUiState.Error("URL is required for bookmarks")
return@launch
}
}
ContentType.NOTE -> {
if (currentTitle.isBlank()) {
_uiState.value = EditLinkUiState.Error("Title is required for notes")
return@launch
}
}
}
linkRepository.updateLink(
id = linkId,
url = if (_contentType.value == ContentType.NOTE && currentUrl.isBlank())
"note://local/${System.currentTimeMillis()}" else currentUrl,
title = currentTitle.ifBlank { null },
description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value
).fold(
onSuccess = {
_uiState.value = EditLinkUiState.Success
},
onFailure = { error ->
_uiState.value = EditLinkUiState.Error(error.message ?: "Failed to update link")
}
)
}
}
}
sealed class EditLinkUiState {
object Loading : EditLinkUiState()
object Loaded : EditLinkUiState()
object Saving : EditLinkUiState()
object Success : EditLinkUiState()
data class Error(val message: String) : EditLinkUiState()
}
sealed class AiEnrichmentState {
object Idle : AiEnrichmentState()
object Loading : AiEnrichmentState()
object Success : AiEnrichmentState()
}