- 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
269 lines
9.1 KiB
Kotlin
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()
|
|
}
|