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

736 lines
35 KiB
Kotlin

package com.shaarit.presentation.edit
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.shaarit.presentation.add.ContentType
import com.shaarit.ui.components.*
import com.shaarit.ui.theme.*
import com.shaarit.ui.theme.Purple
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun EditLinkScreen(
onNavigateBack: () -> Unit,
viewModel: EditLinkViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val url by viewModel.url.collectAsState()
val title by viewModel.title.collectAsState()
val description by viewModel.description.collectAsState()
val selectedTags by viewModel.selectedTags.collectAsState()
val newTagInput by viewModel.newTagInput.collectAsState()
val availableTags by viewModel.availableTags.collectAsState()
val isPrivate by viewModel.isPrivate.collectAsState()
val tagSuggestions by viewModel.tagSuggestions.collectAsState()
val contentType by viewModel.contentType.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
var showMarkdownPreview by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
val aiEnrichmentState by viewModel.aiEnrichmentState.collectAsState()
// State pour l'éditeur Markdown avec barre d'outils flottante
val markdownEditorState = rememberMarkdownEditorState()
// Pour faire défiler automatiquement vers la section tags quand le clavier s'ouvre
val tagsSectionBringIntoViewRequester = remember { BringIntoViewRequester() }
LaunchedEffect(uiState) {
when (val state = uiState) {
is EditLinkUiState.Success -> {
onNavigateBack()
}
is EditLinkUiState.Error -> {
snackbarHostState.showSnackbar(state.message)
}
else -> {}
}
}
LaunchedEffect(Unit) {
viewModel.aiErrorMessage.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
LaunchedEffect(aiEnrichmentState) {
if (aiEnrichmentState is AiEnrichmentState.Success) {
snackbarHostState.showSnackbar("✨ Enrichissement IA appliqué !")
viewModel.resetAiEnrichmentState()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient(colors = listOf(DeepNavy, DarkNavy))
)
) {
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
if (contentType == ContentType.NOTE) "Modifier la note" else "Modifier le lien",
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 = androidx.compose.ui.graphics.Color.Transparent
) { paddingValues ->
when (uiState) {
is EditLinkUiState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = CyanPrimary)
Spacer(modifier = Modifier.height(16.dp))
Text(
"Chargement...",
color = TextSecondary,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
else -> {
Column(
modifier = Modifier
.padding(paddingValues)
.padding(horizontal = 16.dp)
.fillMaxSize()
.imePadding()
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Content Type Selection (compact)
GlassCard(
modifier = Modifier.fillMaxWidth(),
glowColor = CyanPrimary
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Bookmark option
ContentTypeButton(
icon = Icons.Default.Bookmark,
label = "Lien",
isSelected = contentType == ContentType.BOOKMARK,
onClick = { viewModel.setContentType(ContentType.BOOKMARK) },
modifier = Modifier.weight(1f)
)
// Note option
ContentTypeButton(
icon = Icons.Default.StickyNote2,
label = "Note",
isSelected = contentType == ContentType.NOTE,
onClick = { viewModel.setContentType(ContentType.NOTE) },
modifier = Modifier.weight(1f)
)
}
}
// URL Section (compact, only for Bookmarks)
AnimatedVisibility(
visible = contentType == ContentType.BOOKMARK,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
CompactFieldCard(
icon = Icons.Default.Link,
label = "URL"
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = url,
onValueChange = { viewModel.url.value = it },
modifier = Modifier.weight(1f),
placeholder = { Text("https://example.com", color = TextMuted) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
colors = compactTextFieldColors(),
shape = RoundedCornerShape(8.dp),
textStyle = MaterialTheme.typography.bodyMedium
)
// AI Magic Button
AiMagicButton(
onClick = { viewModel.analyzeUrlWithAi() },
isLoading = aiEnrichmentState is AiEnrichmentState.Loading,
enabled = url.isNotBlank() && aiEnrichmentState !is AiEnrichmentState.Loading
)
}
}
}
// Title Section (compact)
CompactFieldCard(
icon = Icons.Default.Title,
label = if (contentType == ContentType.NOTE) "Titre *" else "Titre"
) {
OutlinedTextField(
value = title,
onValueChange = { viewModel.title.value = it },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
if (contentType == ContentType.NOTE)
"Titre de la note" else "Titre du lien",
color = TextMuted
)
},
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
colors = compactTextFieldColors(),
shape = RoundedCornerShape(8.dp),
textStyle = MaterialTheme.typography.bodyMedium
)
}
// Description Section - Markdown Editor (plus grand en mode Note)
GlassCard(
modifier = Modifier
.fillMaxWidth()
.then(
if (contentType == ContentType.NOTE)
Modifier.heightIn(min = 400.dp)
else
Modifier
)
) {
Column {
// Header avec titre et toggle édition/apercu
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.Description,
contentDescription = null,
tint = CyanPrimary,
modifier = Modifier.size(20.dp)
)
Column {
Text(
text = if (contentType == ContentType.NOTE)
"Contenu" else "Description",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = TextPrimary
)
if (contentType == ContentType.NOTE) {
Text(
text = "Markdown supporté",
style = MaterialTheme.typography.labelSmall,
color = TextMuted
)
}
}
}
// Toggle édition/apercu simple
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
IconButton(
onClick = { showMarkdownPreview = false },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.Edit,
contentDescription = "Éditer",
tint = if (!showMarkdownPreview) CyanPrimary else TextMuted,
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = { showMarkdownPreview = true },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.Preview,
contentDescription = "Aperçu",
tint = if (showMarkdownPreview) CyanPrimary else TextMuted,
modifier = Modifier.size(18.dp)
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Éditeur Markdown ou Aperçu
if (showMarkdownPreview) {
MarkdownPreview(
markdown = description,
modifier = Modifier
.fillMaxWidth()
.heightIn(
min = if (contentType == ContentType.NOTE) 300.dp else 150.dp,
max = if (contentType == ContentType.NOTE) 500.dp else 300.dp
)
)
} else {
SimpleMarkdownEditor(
value = description,
onValueChange = { viewModel.description.value = it },
editorState = markdownEditorState,
modifier = Modifier
.fillMaxWidth()
.heightIn(
min = if (contentType == ContentType.NOTE) 300.dp else 150.dp,
max = if (contentType == ContentType.NOTE) 500.dp else 300.dp
),
isNoteMode = contentType == ContentType.NOTE,
placeholder = if (contentType == ContentType.NOTE)
"Écrivez votre note ici..."
else
"Ajoutez une description..."
)
}
}
}
// Tags Section avec correction du clavier - se positionne au-dessus du clavier
GlassCard(
modifier = Modifier
.fillMaxWidth()
.bringIntoViewRequester(tagsSectionBringIntoViewRequester)
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.Tag,
contentDescription = null,
tint = CyanPrimary,
modifier = Modifier.size(20.dp)
)
Text(
text = "Tags",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = TextPrimary
)
}
Spacer(modifier = Modifier.height(12.dp))
// Selected tags
if (selectedTags.isNotEmpty()) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(bottom = 12.dp)
) {
items(selectedTags) { tag ->
TagChip(
tag = tag,
isSelected = true,
onClick = { viewModel.removeTag(tag) }
)
}
}
}
// New tag input - fermer le clavier sur Done
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = newTagInput,
onValueChange = { viewModel.onNewTagInputChanged(it) },
modifier = Modifier
.weight(1f)
.onFocusChanged { focusState ->
if (focusState.isFocused) {
// Faire défiler vers la section tags quand le champ est focusé
coroutineScope.launch {
tagsSectionBringIntoViewRequester.bringIntoView()
}
}
},
placeholder = { Text("Ajouter un tag...", color = TextMuted) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
if (newTagInput.isNotBlank()) {
viewModel.addNewTag()
}
focusManager.clearFocus()
}
),
colors = compactTextFieldColors(),
shape = RoundedCornerShape(8.dp),
textStyle = MaterialTheme.typography.bodyMedium
)
IconButton(
onClick = {
viewModel.addNewTag()
focusManager.clearFocus()
},
enabled = newTagInput.isNotBlank(),
modifier = Modifier.size(40.dp)
) {
Icon(
Icons.Default.Add,
contentDescription = "Ajouter",
tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted
)
}
}
// Tag suggestions
AnimatedVisibility(
visible = tagSuggestions.isNotEmpty(),
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
"Suggestions",
style = MaterialTheme.typography.labelSmall,
color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp)
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(tagSuggestions.take(8)) { tag ->
TagChip(
tag = tag.name,
isSelected = false,
onClick = {
viewModel.addTag(tag.name)
focusManager.clearFocus()
},
count = tag.occurrences
)
}
}
}
}
// Popular tags
if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
"Populaires",
style = MaterialTheme.typography.labelSmall,
color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp)
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
availableTags
.filter { it.name !in selectedTags }
.take(8)
) { tag ->
TagChip(
tag = tag.name,
isSelected = false,
onClick = { viewModel.addTag(tag.name) },
count = tag.occurrences
)
}
}
}
}
}
}
// Privacy Section (compact)
CompactFieldCard(
icon = if (isPrivate) Icons.Default.Lock else Icons.Default.Public,
label = if (isPrivate) "Privé" else "Public",
onClick = { viewModel.isPrivate.value = !isPrivate }
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
if (isPrivate) "Seul vous pouvez voir" else "Visible par tous",
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary
)
Switch(
checked = isPrivate,
onCheckedChange = { viewModel.isPrivate.value = it },
colors = SwitchDefaults.colors(
checkedThumbColor = CyanPrimary,
checkedTrackColor = CyanPrimary.copy(alpha = 0.3f),
uncheckedThumbColor = TextMuted,
uncheckedTrackColor = SurfaceVariant
)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Update Button
GradientButton(
text = if (uiState is EditLinkUiState.Saving) "Enregistrement..." else
if (contentType == ContentType.NOTE) "Enregistrer la note" else "Enregistrer les modifications",
onClick = {
focusManager.clearFocus()
viewModel.updateLink()
},
modifier = Modifier.fillMaxWidth(),
enabled = when (contentType) {
ContentType.BOOKMARK -> url.isNotBlank() && uiState !is EditLinkUiState.Saving
ContentType.NOTE -> title.isNotBlank() && uiState !is EditLinkUiState.Saving
}
)
if (uiState is EditLinkUiState.Saving) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
color = CyanPrimary,
trackColor = SurfaceVariant
)
}
Spacer(modifier = Modifier.height(80.dp)) // Espace pour la barre d'outils flottante
}
}
}
}
// Barre d'outils Markdown flottante - collée au-dessus du clavier
FloatingMarkdownToolbar(
editorState = markdownEditorState,
onValueChange = { viewModel.description.value = it },
visible = !showMarkdownPreview,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
/**
* Bouton de sélection de type de contenu compact
*/
@Composable
private fun ContentTypeButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
onClick = onClick,
shape = RoundedCornerShape(10.dp),
color = if (isSelected) CyanPrimary.copy(alpha = 0.15f) else CardBackgroundElevated,
border = if (isSelected) androidx.compose.foundation.BorderStroke(1.5.dp, CyanPrimary) else null,
modifier = modifier
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (isSelected) CyanPrimary else TextSecondary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = if (isSelected) CyanPrimary else TextPrimary,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal
)
}
}
}
/**
* Carte de champ compacte
*/
@Composable
private fun CompactFieldCard(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit
) {
val cardModifier = Modifier.fillMaxWidth()
val finalModifier = if (onClick != null) {
cardModifier.clickable(onClick = onClick)
} else {
cardModifier
}
GlassCard(
modifier = finalModifier,
glowColor = CyanPrimary.copy(alpha = 0.3f)
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(bottom = 8.dp)
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = CyanPrimary,
modifier = Modifier.size(18.dp)
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = TextSecondary,
fontWeight = FontWeight.Medium
)
}
content()
}
}
}
/**
* Couleurs pour les champs texte compacts
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun compactTextFieldColors() = OutlinedTextFieldDefaults.colors(
focusedBorderColor = CyanPrimary,
unfocusedBorderColor = SurfaceVariant,
focusedLabelColor = CyanPrimary,
unfocusedLabelColor = TextSecondary,
cursorColor = CyanPrimary,
focusedContainerColor = CardBackground.copy(alpha = 0.3f),
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)
)
}
}
}
}