feat: Implement dead links management screen allowing pagination, selection, deletion, and re-verification.
This commit is contained in:
parent
a05f7ce71c
commit
9479986e33
@ -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,28 +148,79 @@ 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.
|
||||||
|
|
||||||
DeadLinkItem(
|
val testResult = testResults[link.id]
|
||||||
link = link,
|
val dismissState = rememberDismissState(
|
||||||
isSelected = selectedLinkIds.contains(link.id),
|
confirmValueChange = { dismissValue ->
|
||||||
onItemClick = {
|
when (dismissValue) {
|
||||||
if (isSelectionMode) {
|
DismissValue.DismissedToStart -> {
|
||||||
viewModel.toggleSelection(link.id)
|
viewModel.deleteLink(link.id)
|
||||||
} else {
|
true
|
||||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
|
}
|
||||||
context.startActivity(intent)
|
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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongClick = {
|
dismissContent = {
|
||||||
viewModel.toggleSelection(link.id)
|
DeadLinkItem(
|
||||||
},
|
link = link,
|
||||||
onLinkClick = { url ->
|
isSelected = selectedLinkIds.contains(link.id),
|
||||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
testResult = testResult,
|
||||||
context.startActivity(intent)
|
onItemClick = {
|
||||||
},
|
if (isSelectionMode) {
|
||||||
onEditClick = onNavigateToEdit,
|
viewModel.toggleSelection(link.id)
|
||||||
onDeleteClick = { viewModel.deleteLink(link.id) },
|
} else {
|
||||||
onTagClick = { },
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
|
||||||
onViewClick = { }
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
viewModel.toggleSelection(link.id)
|
||||||
|
},
|
||||||
|
onLinkClick = { url ->
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
onEditClick = onNavigateToEdit,
|
||||||
|
onDeleteClick = { viewModel.deleteLink(link.id) },
|
||||||
|
onTagClick = { },
|
||||||
|
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,14 +256,45 @@ 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
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
ListViewItem(
|
Column {
|
||||||
link = link,
|
ListViewItem(
|
||||||
onItemClick = onItemClick,
|
link = link,
|
||||||
onLinkClick = onLinkClick,
|
onItemClick = onItemClick,
|
||||||
onEditClick = { onEditClick(link.id) },
|
onLinkClick = onLinkClick,
|
||||||
onDeleteClick = { onDeleteClick(link.id) },
|
onEditClick = { onEditClick(link.id) },
|
||||||
onTagClick = onTagClick,
|
onDeleteClick = { onDeleteClick(link.id) },
|
||||||
onViewClick = { onViewClick(link.id) }
|
onTagClick = onTagClick,
|
||||||
)
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user