- Document automatic token verification on startup and note:// prefix for Markdown notes - Add extensive AI capabilities section (Gemini integration, auto-tagging, content classification, multi-model fallback) - Document link health monitoring system with dead link detection and exclusion features - Add file sharing support (Markdown/text files) and deep links documentation - Update technology
904 lines
36 KiB
Kotlin
904 lines
36 KiB
Kotlin
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
|
|
)
|
|
}
|
|
}
|
|
}
|