feat: Implement dead links management screen allowing pagination, selection, deletion, and re-verification.

This commit is contained in:
Bruno Charest 2026-02-23 09:01:54 -05:00
parent a05f7ce71c
commit 9479986e33
3 changed files with 177 additions and 32 deletions

View File

@ -13,10 +13,12 @@ import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.animation.animateColorAsState
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -40,6 +42,7 @@ fun DeadLinksScreen(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val selectedLinkIds by viewModel.selectedLinkIds.collectAsState() val selectedLinkIds by viewModel.selectedLinkIds.collectAsState()
val isSelectionMode by viewModel.isSelectionMode.collectAsState() val isSelectionMode by viewModel.isSelectionMode.collectAsState()
val testResults by viewModel.linkTestResults.collectAsState()
Box( Box(
modifier = Modifier modifier = Modifier
@ -145,9 +148,58 @@ fun DeadLinksScreen(
// on va devoir le modifier ou wrapper l'item. // on va devoir le modifier ou wrapper l'item.
// Le viewmodel gère la suppression. // Le viewmodel gère la suppression.
val testResult = testResults[link.id]
val dismissState = rememberDismissState(
confirmValueChange = { dismissValue ->
when (dismissValue) {
DismissValue.DismissedToStart -> {
viewModel.deleteLink(link.id)
true
}
DismissValue.DismissedToEnd -> {
viewModel.verifyLink(link.id, link.url)
false
}
else -> false
}
}
)
SwipeToDismiss(
state = dismissState,
background = {
val direction = dismissState.dismissDirection ?: return@SwipeToDismiss
val color by animateColorAsState(
when (dismissState.targetValue) {
DismissValue.Default -> Color.Transparent
DismissValue.DismissedToEnd -> Color(0xFF10B981)
DismissValue.DismissedToStart -> Color.Red
}
)
val alignment = when (direction) {
DismissDirection.StartToEnd -> Alignment.CenterStart
DismissDirection.EndToStart -> Alignment.CenterEnd
}
val icon = when (direction) {
DismissDirection.StartToEnd -> Icons.Default.Refresh
DismissDirection.EndToStart -> Icons.Default.Delete
}
Box(
Modifier
.fillMaxSize()
.background(color)
.padding(horizontal = 20.dp),
contentAlignment = alignment
) {
Icon(icon, contentDescription = null, tint = Color.White)
}
},
dismissContent = {
DeadLinkItem( DeadLinkItem(
link = link, link = link,
isSelected = selectedLinkIds.contains(link.id), isSelected = selectedLinkIds.contains(link.id),
testResult = testResult,
onItemClick = { onItemClick = {
if (isSelectionMode) { if (isSelectionMode) {
viewModel.toggleSelection(link.id) viewModel.toggleSelection(link.id)
@ -169,6 +221,8 @@ fun DeadLinksScreen(
onViewClick = { } onViewClick = { }
) )
} }
)
}
} }
} }
} }
@ -182,6 +236,7 @@ fun DeadLinksScreen(
private fun DeadLinkItem( private fun DeadLinkItem(
link: com.shaarit.domain.model.ShaarliLink, link: com.shaarit.domain.model.ShaarliLink,
isSelected: Boolean, isSelected: Boolean,
testResult: LinkTestResult?,
onItemClick: () -> Unit, onItemClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
@ -201,6 +256,7 @@ private fun DeadLinkItem(
containerColor = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) else MaterialTheme.colorScheme.surfaceVariant containerColor = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) else MaterialTheme.colorScheme.surfaceVariant
) )
) { ) {
Column {
ListViewItem( ListViewItem(
link = link, link = link,
onItemClick = onItemClick, onItemClick = onItemClick,
@ -210,5 +266,35 @@ private fun DeadLinkItem(
onTagClick = onTagClick, onTagClick = onTagClick,
onViewClick = { onViewClick(link.id) } onViewClick = { onViewClick(link.id) }
) )
if (testResult != null) {
when (testResult) {
is LinkTestResult.Loading -> {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
is LinkTestResult.Success -> {
Text(
"Le lien fonctionne ! Statut mis à jour.",
color = Color(0xFF10B981),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold
)
}
is LinkTestResult.Error -> {
Row(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Close, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(
"Erreur : ${testResult.message}",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
} }
} }

View File

@ -65,4 +65,63 @@ class DeadLinksViewModel @Inject constructor(
linkRepository.deleteLink(id) linkRepository.deleteLink(id)
} }
} }
private val _linkTestResults = MutableStateFlow<Map<Int, LinkTestResult>>(emptyMap())
val linkTestResults: StateFlow<Map<Int, LinkTestResult>> = _linkTestResults.asStateFlow()
fun verifyLink(id: Int, url: String) {
viewModelScope.launch {
_linkTestResults.value = _linkTestResults.value + (id to LinkTestResult.Loading)
val result = performLinkCheck(url)
if (result is LinkTestResult.Success) {
// Mettre à jour le statut du lien dans la base de données
linkDao.updateLinkHealthStatus(
id = id,
status = com.shaarit.data.local.entity.LinkCheckStatus.VALID,
failCount = 0,
timestamp = System.currentTimeMillis()
)
}
_linkTestResults.value = _linkTestResults.value + (id to result)
}
}
private suspend fun performLinkCheck(urlStr: String): LinkTestResult = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
var connection: java.net.HttpURLConnection? = null
try {
val url = java.net.URL(urlStr)
connection = url.openConnection() as java.net.HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = 7000
connection.readTimeout = 7000
connection.instanceFollowRedirects = true
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36")
connection.connect()
val responseCode = connection.responseCode
if (responseCode in 200..399 || responseCode == 401 || responseCode == 403 || responseCode == 405 || responseCode == 429) {
return@withContext LinkTestResult.Success
} else {
return@withContext LinkTestResult.Error("Erreur $responseCode")
}
} catch (e: java.net.UnknownHostException) {
return@withContext LinkTestResult.Error("Erreur DNS")
} catch (e: java.net.SocketTimeoutException) {
return@withContext LinkTestResult.Error("Délai d'attente dépassé")
} catch (e: javax.net.ssl.SSLHandshakeException) {
return@withContext LinkTestResult.Error("Erreur SSL")
} catch (e: java.io.FileNotFoundException) {
return@withContext LinkTestResult.Error("Erreur 404")
} catch (e: Exception) {
return@withContext LinkTestResult.Error(e.message ?: "Erreur inconnue")
} finally {
connection?.disconnect()
}
}
}
sealed class LinkTestResult {
object Loading : LinkTestResult()
object Success : LinkTestResult()
data class Error(val message: String) : LinkTestResult()
} }

View File

@ -1,3 +1,3 @@
#Sun Feb 22 14:59:52 2026 #Sun Feb 22 21:42:41 2026
VERSION_NAME=2.5.1 VERSION_NAME=2.7.0
VERSION_CODE=28 VERSION_CODE=30