package com.shaarit.presentation.feed import android.content.Intent import android.net.Uri import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.BrokenImage import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.HelpOutline import androidx.compose.material.icons.filled.Warning import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.ui.window.DialogProperties 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.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.shaarit.domain.model.HealthStatus import com.shaarit.domain.model.ShaarliLink import com.shaarit.ui.components.GlassCard import com.shaarit.ui.components.TagChip import com.shaarit.ui.theme.Typography import dev.jeziellago.compose.markdowntext.MarkdownText import coil.compose.AsyncImage import coil.request.ImageRequest import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.graphics.Color /** * Full list view item - shows all details including markdown description */ @OptIn(ExperimentalLayoutApi::class) @Composable fun ListViewItem( link: ShaarliLink, onTagClick: (String) -> Unit, onLinkClick: (String) -> Unit, onItemClick: (() -> Unit)? = null, onItemLongClick: (() -> Unit)? = null, selectionMode: Boolean = false, isSelected: Boolean = false, onViewClick: () -> Unit, onEditClick: (Int) -> Unit, onDeleteClick: () -> Unit, onTogglePin: (Int) -> Unit = {} ) { val haptic = LocalHapticFeedback.current var showDeleteDialog by remember { mutableStateOf(false) } if (showDeleteDialog) { DeleteConfirmationDialog( linkTitle = link.displayTitle, onConfirm = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) onDeleteClick() showDeleteDialog = false }, onDismiss = { showDeleteDialog = false } ) } GlassCard( modifier = Modifier.fillMaxWidth(), onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() }, onLongClick = onItemLongClick, glowColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary ) { Column { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top ) { // Thumbnail (List View) if (!link.thumbnailUrl.isNullOrBlank()) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(link.thumbnailUrl) .size(200) .crossfade(true) .memoryCacheKey(link.thumbnailUrl) .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .size(100.dp) .clip(RoundedCornerShape(12.dp)) ) } Column(modifier = Modifier.weight(1f)) { Text( text = link.displayTitle, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary, maxLines = 2, overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.height(4.dp)) Row(verticalAlignment = Alignment.CenterVertically) { HealthStatusIcon( healthStatus = link.healthStatus, modifier = Modifier.size(16.dp).padding(end = 4.dp) ) Text( text = link.url, style = MaterialTheme.typography.bodySmall, color = when (link.healthStatus) { HealthStatus.DEAD -> MaterialTheme.colorScheme.error HealthStatus.OK -> MaterialTheme.colorScheme.secondary else -> MaterialTheme.colorScheme.outline }, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } Row { if (selectionMode) { Checkbox( checked = isSelected, onCheckedChange = { onItemClick?.invoke() } ) } // Pin button IconButton( onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) onTogglePin(link.id) }, modifier = Modifier.size(32.dp) ) { Icon( imageVector = Icons.Default.PushPin, contentDescription = if (link.isPinned) "Désépingler" else "Épingler", tint = if (link.isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, modifier = Modifier.size(18.dp) ) } IconButton(onClick = onViewClick, modifier = Modifier.size(32.dp)) { Icon( imageVector = Icons.Default.Visibility, contentDescription = "View Details", tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), modifier = Modifier.size(18.dp) ) } IconButton(onClick = { onEditClick(link.id) }, modifier = Modifier.size(32.dp)) { Icon( imageVector = Icons.Default.Edit, contentDescription = "Edit", tint = MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f), modifier = Modifier.size(18.dp) ) } IconButton(onClick = { showDeleteDialog = true }, modifier = Modifier.size(32.dp)) { Icon( imageVector = Icons.Default.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f), modifier = Modifier.size(18.dp) ) } } } if (link.description.isNotBlank()) { Spacer(modifier = Modifier.height(8.dp)) Text( text = link.description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 5, overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth() ) } if (link.tags.isNotEmpty()) { Spacer(modifier = Modifier.height(12.dp)) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(link.tags) { tag -> TagChip(tag = tag, isSelected = false, onClick = { onTagClick(tag) }) } } } Spacer(modifier = Modifier.height(12.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = link.date, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline ) if (link.isPrivate) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Icon( Icons.Default.Lock, contentDescription = "Private", tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(12.dp) ) Text( text = "Private", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline ) } } } } } } /** * Grid view item - compact cards in a 2-column grid */ @Composable fun GridViewItem( link: ShaarliLink, onLinkClick: (String) -> Unit, onItemClick: (() -> Unit)? = null, onItemLongClick: (() -> Unit)? = null, selectionMode: Boolean = false, isSelected: Boolean = false, onViewClick: () -> Unit, onEditClick: (Int) -> Unit, onDeleteClick: () -> Unit, onTogglePin: (Int) -> Unit = {} ) { val haptic = LocalHapticFeedback.current var showDeleteDialog by remember { mutableStateOf(false) } if (showDeleteDialog) { DeleteConfirmationDialog( linkTitle = link.displayTitle, onConfirm = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) onDeleteClick() showDeleteDialog = false }, onDismiss = { showDeleteDialog = false } ) } GlassCard( modifier = Modifier .fillMaxWidth() .heightIn(min = 220.dp), onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() }, onLongClick = onItemLongClick, glowColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary ) { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.SpaceBetween ) { Column { // Thumbnail (Grid View) if (!link.thumbnailUrl.isNullOrBlank()) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(link.thumbnailUrl) .size(400, 280) .crossfade(true) .memoryCacheKey(link.thumbnailUrl) .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxWidth() .height(140.dp) .padding(bottom = 12.dp) .clip(RoundedCornerShape(12.dp)) ) } // Title with pin indicator Row( verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() ) { HealthStatusIcon( healthStatus = link.healthStatus, modifier = Modifier.size(16.dp).padding(end = 4.dp).align(Alignment.CenterVertically) ) Text( text = link.displayTitle, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = when (link.healthStatus) { HealthStatus.DEAD -> MaterialTheme.colorScheme.error HealthStatus.OK -> MaterialTheme.colorScheme.primary else -> MaterialTheme.colorScheme.primary.copy(alpha = 0.7f) }, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) if (link.isPinned) { Icon( imageVector = Icons.Default.PushPin, contentDescription = "Épinglé", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp) ) } } Spacer(modifier = Modifier.height(4.dp)) // Description (plain text for scroll performance) if (link.description.isNotBlank()) { Text( text = link.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth() ) } } Column { // Tags (show only first 2) if (link.tags.isNotEmpty()) { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(bottom = 8.dp) ) { link.tags.take(2).forEach { tag -> Text( text = "#$tag", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis ) } if (link.tags.size > 2) { Text( text = "+${link.tags.size - 2}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline ) } } } // Actions row Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { if (selectionMode) { Checkbox( checked = isSelected, onCheckedChange = { onItemClick?.invoke() } ) } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { if (link.isPrivate) { Icon( Icons.Default.Lock, contentDescription = "Private", tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(12.dp) ) } Text( text = link.date.take(10), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline ) } Row { // Pin button IconButton( onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) onTogglePin(link.id) }, modifier = Modifier.size(24.dp) ) { Icon( imageVector = Icons.Default.PushPin, contentDescription = if (link.isPinned) "Désépingler" else "Épingler", tint = if (link.isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, modifier = Modifier.size(14.dp) ) } IconButton( onClick = onViewClick, modifier = Modifier.size(24.dp) ) { Icon( imageVector = Icons.Default.Visibility, contentDescription = "View Details", tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), modifier = Modifier.size(14.dp) ) } IconButton( onClick = { onEditClick(link.id) }, modifier = Modifier.size(24.dp) ) { Icon( imageVector = Icons.Default.Edit, contentDescription = "Edit", tint = MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f), modifier = Modifier.size(14.dp) ) } IconButton( onClick = { showDeleteDialog = true }, modifier = Modifier.size(24.dp) ) { Icon( imageVector = Icons.Default.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f), modifier = Modifier.size(14.dp) ) } } } } } } } /** * Compact view item - minimal info for dense lists */ @Composable @OptIn(ExperimentalFoundationApi::class) fun CompactViewItem( link: ShaarliLink, onLinkClick: (String) -> Unit, onItemClick: (() -> Unit)? = null, onItemLongClick: (() -> Unit)? = null, selectionMode: Boolean = false, isSelected: Boolean = false, onViewClick: () -> Unit, onEditClick: (Int) -> Unit, onDeleteClick: () -> Unit, onTogglePin: (Int) -> Unit = {} ) { val haptic = LocalHapticFeedback.current var showDeleteDialog by remember { mutableStateOf(false) } if (showDeleteDialog) { DeleteConfirmationDialog( linkTitle = link.displayTitle, onConfirm = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) onDeleteClick() showDeleteDialog = false }, onDismiss = { showDeleteDialog = false } ) } Surface( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .combinedClickable( onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() }, onLongClick = onItemLongClick ), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f) ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 10.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { if (selectionMode) { Checkbox( checked = isSelected, onCheckedChange = { onItemClick?.invoke() } ) Spacer(modifier = Modifier.width(8.dp)) } Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { if (link.isPinned) { Icon( imageVector = Icons.Default.PushPin, contentDescription = "Épinglé", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(14.dp) ) } if (link.isPrivate) { Icon( Icons.Default.Lock, contentDescription = "Private", tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(14.dp) ) } Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { HealthStatusIcon( healthStatus = link.healthStatus, modifier = Modifier.size(14.dp).padding(end = 4.dp) ) Text( text = link.displayTitle, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = when (link.healthStatus) { HealthStatus.DEAD -> MaterialTheme.colorScheme.error HealthStatus.OK -> MaterialTheme.colorScheme.primary else -> MaterialTheme.colorScheme.primary.copy(alpha = 0.7f) }, maxLines = 1, overflow = TextOverflow.Ellipsis ) } Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = link.date.take(10), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline ) if (link.tags.isNotEmpty()) { Text( text = link.tags.take(2).joinToString { "#$it" }, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } } } Row { IconButton( onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) onTogglePin(link.id) }, modifier = Modifier.size(28.dp) ) { Icon( imageVector = Icons.Default.PushPin, contentDescription = if (link.isPinned) "Désépingler" else "Épingler", tint = if (link.isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, modifier = Modifier.size(16.dp) ) } IconButton(onClick = onViewClick, modifier = Modifier.size(28.dp)) { Icon( imageVector = Icons.Default.Visibility, contentDescription = "View Details", tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), modifier = Modifier.size(16.dp) ) } IconButton(onClick = { onEditClick(link.id) }, modifier = Modifier.size(28.dp)) { Icon( imageVector = Icons.Default.Edit, contentDescription = "Edit", tint = MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f), modifier = Modifier.size(16.dp) ) } IconButton(onClick = { showDeleteDialog = true }, modifier = Modifier.size(28.dp)) { Icon( imageVector = Icons.Default.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f), modifier = Modifier.size(16.dp) ) } } } } } /** * Reusable delete confirmation dialog */ @Composable fun DeleteConfirmationDialog( linkTitle: String, onConfirm: () -> Unit, onDismiss: () -> Unit ) { AlertDialog( onDismissRequest = onDismiss, title = { Text("Delete Link", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground) }, text = { Column { Text("Are you sure you want to delete this link?", color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(modifier = Modifier.height(8.dp)) Text( linkTitle, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis ) } }, confirmButton = { TextButton(onClick = onConfirm) { Text("Delete", color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel", color = MaterialTheme.colorScheme.outline) } }, containerColor = MaterialTheme.colorScheme.surfaceVariant, titleContentColor = MaterialTheme.colorScheme.onBackground, textContentColor = MaterialTheme.colorScheme.onSurfaceVariant ) } /** * Dialog to show full link details */ /** * Full screen link details view (not a dialog window, so we can animate it) */ /** * Full screen link details view (not a dialog window, so we can animate it) */ @OptIn(ExperimentalLayoutApi::class) @Composable fun LinkDetailsView( link: ShaarliLink, onDismiss: () -> Unit, onLinkClick: (String) -> Unit ) { Box( modifier = Modifier .fillMaxSize() .clickable(onClick = onDismiss) // Click outside to dismiss .background(Color.Black.copy(alpha = 0.6f)), contentAlignment = Alignment.Center ) { // Stop propagation of clicks to the background GlassCard( modifier = Modifier .padding(16.dp) .fillMaxWidth() .fillMaxHeight(0.9f) .clickable(enabled = false, onClick = {}) ) { Column( modifier = Modifier .padding(24.dp) .fillMaxSize() ) { // Header Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top ) { Text( text = link.displayTitle, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f) ) IconButton( onClick = onDismiss, modifier = Modifier .size(32.dp) .offset(x = 8.dp, y = (-8).dp) ) { Icon( imageVector = Icons.Default.Close, contentDescription = "Close", tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } Spacer(modifier = Modifier.height(16.dp)) // Hero Image in Details if (!link.thumbnailUrl.isNullOrBlank()) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(link.thumbnailUrl) .size(800, 400) .crossfade(true) .memoryCacheKey(link.thumbnailUrl) .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxWidth() .height(200.dp) .clip(RoundedCornerShape(16.dp)) ) Spacer(modifier = Modifier.height(16.dp)) } // Scrollable Content Column( modifier = Modifier .weight(1f) .verticalScroll(androidx.compose.foundation.rememberScrollState()) ) { // URL Text( text = link.url, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, modifier = Modifier .clickable { onLinkClick(link.url) } .padding(vertical = 4.dp) ) Spacer(modifier = Modifier.height(16.dp)) // Tags if (link.tags.isNotEmpty()) { FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { link.tags.forEach { tag -> TagChip( tag = tag, isSelected = false, onClick = { /* No action in dialog */ } ) } } Spacer(modifier = Modifier.height(24.dp)) } // Metadata Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = link.date, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline ) if (link.isPrivate) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Icon( Icons.Default.Lock, contentDescription = "Private", tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(14.dp) ) Text( text = "Private", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline ) } } } Spacer(modifier = Modifier.height(24.dp)) Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) Spacer(modifier = Modifier.height(24.dp)) // Description if (link.description.isNotBlank()) { MarkdownText( markdown = link.description, style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onBackground), modifier = Modifier.fillMaxWidth() ) } else { Text( text = "No description", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline, fontStyle = androidx.compose.ui.text.font.FontStyle.Italic ) } } } } } } /** * Indicateur visuel du statut de santé d'un lien * - NOTE: pas d'icône (c'est une note, pas un lien) * - UNTESTED: icône grise (jamais testé) * - OK: icône verte (testé et fonctionnel) * - DEAD: icône rouge (testé et mort) */ @Composable fun HealthStatusIcon( healthStatus: HealthStatus, modifier: Modifier = Modifier ) { when (healthStatus) { HealthStatus.NOTE -> { // Pas d'icône pour les notes } HealthStatus.UNTESTED -> { Icon( imageVector = Icons.Default.HelpOutline, contentDescription = "Non testé", tint = MaterialTheme.colorScheme.outline, modifier = modifier ) } HealthStatus.OK -> { Icon( imageVector = Icons.Default.CheckCircle, contentDescription = "Lien fonctionnel", tint = Color(0xFF10B981), modifier = modifier ) } HealthStatus.PENDING -> { Icon( imageVector = Icons.Default.Warning, contentDescription = "En attente de confirmation", tint = Color(0xFFFFA726), // Orange modifier = modifier ) } HealthStatus.DEAD -> { Icon( imageVector = Icons.Default.BrokenImage, contentDescription = "Lien mort", tint = MaterialTheme.colorScheme.error, modifier = modifier ) } } }