feat: implement core app screens, viewmodels, and repository layer for link management and navigation
This commit is contained in:
parent
9479986e33
commit
83658cc6c2
@ -30,6 +30,9 @@ interface LinkDao {
|
||||
|
||||
@Query("SELECT * FROM links WHERE id = :id")
|
||||
suspend fun getLinkById(id: Int): LinkEntity?
|
||||
|
||||
@Query("SELECT * FROM links WHERE id IN (:ids)")
|
||||
suspend fun getLinksByIds(ids: List<Int>): List<LinkEntity>
|
||||
|
||||
@Query("SELECT * FROM links WHERE url = :url")
|
||||
suspend fun getLinkByUrl(url: String): LinkEntity?
|
||||
@ -116,6 +119,7 @@ interface LinkDao {
|
||||
@Query("""
|
||||
SELECT * FROM links
|
||||
WHERE url NOT LIKE 'note://%'
|
||||
AND url NOT LIKE 'https://shaarit.app/note/%'
|
||||
AND excluded_from_health_check = 0
|
||||
AND (last_health_check < :timestamp OR last_health_check IS NULL)
|
||||
ORDER BY last_health_check ASC
|
||||
|
||||
@ -48,6 +48,10 @@ class TodoRepositoryImpl @Inject constructor(
|
||||
return todoDao.getTodoById(id)?.toDomain()
|
||||
}
|
||||
|
||||
override suspend fun getTodoByShaarliLinkUrl(url: String): TodoItem? {
|
||||
return todoDao.getTodoByShaarliLinkUrl(url)?.toDomain()
|
||||
}
|
||||
|
||||
override suspend fun upsertTodo(todo: TodoItem): Result<Long> {
|
||||
return runCatching {
|
||||
val normalized = normalizeTodo(todo)
|
||||
|
||||
@ -40,6 +40,7 @@ class LinkHealthCheckWorker @AssistedInject constructor(
|
||||
"note://",
|
||||
"http://shaare",
|
||||
"/shaare",
|
||||
"https://shaarit.app/note/",
|
||||
"file://",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
|
||||
@ -28,7 +28,7 @@ data class ShaarliLink(
|
||||
*/
|
||||
val healthStatus: HealthStatus
|
||||
get() = when {
|
||||
url.startsWith("note://") || url.startsWith("/shaare/") -> HealthStatus.NOTE
|
||||
(url.startsWith("note://") && !url.startsWith("note://todo-")) || url.startsWith("/shaare/") || url.startsWith("https://shaarit.app/note/") -> HealthStatus.NOTE
|
||||
lastHealthCheck == 0L -> HealthStatus.UNTESTED
|
||||
linkCheckStatus == LinkCheckStatus.BROKEN -> HealthStatus.DEAD
|
||||
linkCheckStatus == LinkCheckStatus.PENDING -> HealthStatus.PENDING
|
||||
@ -36,14 +36,23 @@ data class ShaarliLink(
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine si le bookmark est une note
|
||||
* Détermine si le bookmark est une note (et pas une tâche)
|
||||
*/
|
||||
val isNote: Boolean
|
||||
get() = url.startsWith("note://") ||
|
||||
url.startsWith("http://shaare") ||
|
||||
get() = (url.startsWith("note://") && !url.startsWith("note://todo-")) ||
|
||||
url.startsWith("http://shaare") ||
|
||||
url.startsWith("/shaare") ||
|
||||
url.startsWith("https://shaarit.app/note/") ||
|
||||
tags.any { it.lowercase() == "note" || it.lowercase() == "#note" }
|
||||
|
||||
|
||||
/**
|
||||
* Détermine si le bookmark est une tâche (todo)
|
||||
*/
|
||||
val isTodo: Boolean
|
||||
get() = url.startsWith("note://todo-") ||
|
||||
url.startsWith("https://shaarit.app/todo/") ||
|
||||
tags.any { it.equals("todo", ignoreCase = true) }
|
||||
|
||||
/**
|
||||
* Détermine si le bookmark est un lien réseau local
|
||||
*/
|
||||
|
||||
@ -10,6 +10,8 @@ interface TodoRepository {
|
||||
|
||||
suspend fun getTodoById(id: Long): TodoItem?
|
||||
|
||||
suspend fun getTodoByShaarliLinkUrl(url: String): TodoItem?
|
||||
|
||||
suspend fun upsertTodo(todo: TodoItem): Result<Long>
|
||||
|
||||
suspend fun toggleDone(todoId: Long, isDone: Boolean): Result<Unit>
|
||||
|
||||
@ -323,7 +323,7 @@ constructor(
|
||||
|
||||
if (_contentTypeSelection.value == ContentType.NOTE) {
|
||||
if (finalUrl.isBlank()) {
|
||||
finalUrl = "/shaare/${generateRandomId()}"
|
||||
finalUrl = "https://shaarit.app/note/${generateRandomId()}"
|
||||
}
|
||||
if (finalTitle.isNotBlank() && !finalTitle.startsWith("Note: ", ignoreCase = true)) {
|
||||
finalTitle = "Note: $finalTitle"
|
||||
@ -388,7 +388,7 @@ constructor(
|
||||
|
||||
if (_contentTypeSelection.value == ContentType.NOTE) {
|
||||
if (finalUrl.isBlank()) {
|
||||
finalUrl = "/shaare/${generateRandomId()}"
|
||||
finalUrl = "https://shaarit.app/note/${generateRandomId()}"
|
||||
}
|
||||
if (finalTitle.isNotBlank() && !finalTitle.startsWith("Note: ", ignoreCase = true)) {
|
||||
finalTitle = "Note: $finalTitle"
|
||||
|
||||
@ -89,7 +89,7 @@ constructor(
|
||||
originalTags = link.tags
|
||||
|
||||
// Détecter si c'est une note
|
||||
val isNote = link.tags.contains("note") || link.url.startsWith("note://")
|
||||
val isNote = link.tags.contains("note") || link.url.startsWith("note://") || link.url.startsWith("https://shaarit.app/note/")
|
||||
_contentType.value = if (isNote) ContentType.NOTE else ContentType.BOOKMARK
|
||||
|
||||
_uiState.value = EditLinkUiState.Loaded
|
||||
@ -329,7 +329,7 @@ constructor(
|
||||
|
||||
if (_contentType.value == ContentType.NOTE) {
|
||||
if (finalUrl.isBlank()) {
|
||||
finalUrl = "/shaare/${generateRandomId()}"
|
||||
finalUrl = "https://shaarit.app/note/${generateRandomId()}"
|
||||
}
|
||||
if (finalTitle.isNotBlank() && !finalTitle.startsWith("Note: ", ignoreCase = true)) {
|
||||
finalTitle = "Note: $finalTitle"
|
||||
|
||||
@ -284,6 +284,7 @@ fun FeedScreen(
|
||||
onNavigateToReader: (Int) -> Unit = {},
|
||||
onNavigateToReminders: () -> Unit = {},
|
||||
onNavigateToTodo: () -> Unit = {},
|
||||
onNavigateToTodoDetail: (Int) -> Unit = {},
|
||||
onPlayAudio: ((com.shaarit.domain.model.ShaarliLink) -> Unit)? = null,
|
||||
initialTagFilter: String? = null,
|
||||
initialCollectionId: Long? = null,
|
||||
@ -310,6 +311,9 @@ fun FeedScreen(
|
||||
var selectionMode by remember { mutableStateOf(false) }
|
||||
var selectedIds by remember { mutableStateOf(setOf<Int>()) }
|
||||
var showAddToCollectionDialog by remember { mutableStateOf(false) }
|
||||
var showVerificationResultsDialog by remember { mutableStateOf(false) }
|
||||
val linkVerificationResults by viewModel.linkVerificationResults.collectAsState()
|
||||
val isVerifyingLinks by viewModel.isVerifyingLinks.collectAsState()
|
||||
|
||||
// Reminder bottom sheet state
|
||||
var showReminderSheet by remember { mutableStateOf(false) }
|
||||
@ -318,6 +322,25 @@ fun FeedScreen(
|
||||
val reminderViewModel: com.shaarit.presentation.reminders.ReminderViewModel = hiltViewModel()
|
||||
val linkIdsWithReminders by reminderViewModel.linkIdsWithReminders.collectAsState()
|
||||
|
||||
// Helper function to handle link clicks based on type
|
||||
fun handleLinkClick(link: com.shaarit.domain.model.ShaarliLink) {
|
||||
when {
|
||||
link.isNote -> {
|
||||
// For notes, open the detail bottom sheet
|
||||
selectedLink = link
|
||||
}
|
||||
link.isTodo -> {
|
||||
// For tasks, navigate to task detail screen
|
||||
onNavigateToTodoDetail(link.id)
|
||||
}
|
||||
else -> {
|
||||
// Regular link: open in browser
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// États des accordéons du drawer (accordion: un seul ouvert à la fois)
|
||||
var expandedSection by remember { mutableStateOf("main") }
|
||||
|
||||
@ -822,6 +845,19 @@ fun FeedScreen(
|
||||
tint = if (selectedIds.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
viewModel.verifySelectedLinks(selectedIds)
|
||||
showVerificationResultsDialog = true
|
||||
},
|
||||
enabled = selectedIds.isNotEmpty() && !isVerifyingLinks
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Link,
|
||||
contentDescription = "Vérifier les liens",
|
||||
tint = if (selectedIds.isNotEmpty() && !isVerifyingLinks) Color(0xFF10B981) else MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
selectedIds = emptySet()
|
||||
@ -1466,8 +1502,7 @@ fun FeedScreen(
|
||||
if (selectedIds.contains(link.id)) selectedIds - link.id
|
||||
else selectedIds + link.id
|
||||
} else {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
|
||||
context.startActivity(intent)
|
||||
handleLinkClick(link)
|
||||
}
|
||||
},
|
||||
onItemLongClick = {
|
||||
@ -1484,8 +1519,7 @@ fun FeedScreen(
|
||||
selectionMode = selectionMode,
|
||||
isSelected = selectedIds.contains(link.id),
|
||||
onLinkClick = { url ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
context.startActivity(intent)
|
||||
handleLinkClick(link)
|
||||
},
|
||||
onViewClick = { selectedLink = link },
|
||||
onEditClick = onNavigateToEdit,
|
||||
@ -1529,8 +1563,7 @@ fun FeedScreen(
|
||||
if (selectedIds.contains(link.id)) selectedIds - link.id
|
||||
else selectedIds + link.id
|
||||
} else {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
|
||||
context.startActivity(intent)
|
||||
handleLinkClick(link)
|
||||
}
|
||||
},
|
||||
onItemLongClick = {
|
||||
@ -1547,8 +1580,7 @@ fun FeedScreen(
|
||||
selectionMode = selectionMode,
|
||||
isSelected = selectedIds.contains(link.id),
|
||||
onLinkClick = { url ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
context.startActivity(intent)
|
||||
handleLinkClick(link)
|
||||
},
|
||||
onViewClick = { selectedLink = link },
|
||||
onEditClick = onNavigateToEdit,
|
||||
@ -1592,8 +1624,7 @@ fun FeedScreen(
|
||||
if (selectedIds.contains(link.id)) selectedIds - link.id
|
||||
else selectedIds + link.id
|
||||
} else {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
|
||||
context.startActivity(intent)
|
||||
handleLinkClick(link)
|
||||
}
|
||||
},
|
||||
onItemLongClick = {
|
||||
@ -1611,8 +1642,7 @@ fun FeedScreen(
|
||||
isSelected = selectedIds.contains(link.id),
|
||||
onTagClick = viewModel::onTagClicked,
|
||||
onLinkClick = { url ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
context.startActivity(intent)
|
||||
handleLinkClick(link)
|
||||
},
|
||||
onViewClick = { selectedLink = link },
|
||||
onEditClick = onNavigateToEdit,
|
||||
@ -1666,8 +1696,7 @@ fun FeedScreen(
|
||||
link = link,
|
||||
onDismiss = { selectedLink = null },
|
||||
onLinkClick = { url ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
context.startActivity(intent)
|
||||
handleLinkClick(link)
|
||||
},
|
||||
onReadClick = { linkId ->
|
||||
onNavigateToReader(linkId)
|
||||
@ -1720,6 +1749,120 @@ fun FeedScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Link Verification Results Dialog
|
||||
if (showVerificationResultsDialog) {
|
||||
val validCount = linkVerificationResults.values.count { it is LinkVerificationResult.Valid }
|
||||
val brokenCount = linkVerificationResults.values.count { it is LinkVerificationResult.Broken }
|
||||
val skippedCount = linkVerificationResults.values.count { it is LinkVerificationResult.Skipped }
|
||||
val loadingCount = linkVerificationResults.values.count { it is LinkVerificationResult.Loading }
|
||||
val totalCount = linkVerificationResults.size
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
if (!isVerifyingLinks) {
|
||||
showVerificationResultsDialog = false
|
||||
viewModel.clearVerificationResults()
|
||||
selectionMode = false
|
||||
selectedIds = emptySet()
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
if (isVerifyingLinks) "Vérification en cours..." else "Résultats de la vérification"
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
if (isVerifyingLinks) {
|
||||
LinearProgressIndicator(
|
||||
progress = if (totalCount > 0) (totalCount - loadingCount).toFloat() / totalCount else 0f,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
color = Color(0xFF10B981)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
"${totalCount - loadingCount} / $totalCount liens vérifiés",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF10B981),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
"$validCount lien(s) valide(s)",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
"$brokenCount lien(s) inaccessible(s)",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
if (skippedCount > 0) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.SkipNext,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
"$skippedCount note(s) ignorée(s)",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showVerificationResultsDialog = false
|
||||
viewModel.clearVerificationResults()
|
||||
selectionMode = false
|
||||
selectedIds = emptySet()
|
||||
},
|
||||
enabled = !isVerifyingLinks
|
||||
) {
|
||||
Text("Fermer")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Reminder Bottom Sheet
|
||||
if (showReminderSheet && reminderTargetLinkId > 0) {
|
||||
com.shaarit.presentation.reminders.ReminderBottomSheet(
|
||||
|
||||
@ -34,6 +34,8 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class Quadruple<A, B, C, D>(
|
||||
val first: A,
|
||||
@ -228,4 +230,90 @@ class FeedViewModel @Inject constructor(
|
||||
_refreshTrigger.value++
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Vérification de liens sélectionnés ======
|
||||
|
||||
private val _linkVerificationResults = MutableStateFlow<Map<Int, LinkVerificationResult>>(emptyMap())
|
||||
val linkVerificationResults = _linkVerificationResults.asStateFlow()
|
||||
|
||||
private val _isVerifyingLinks = MutableStateFlow(false)
|
||||
val isVerifyingLinks = _isVerifyingLinks.asStateFlow()
|
||||
|
||||
fun verifySelectedLinks(selectedIds: Set<Int>) {
|
||||
if (selectedIds.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
_isVerifyingLinks.value = true
|
||||
// Marquer tous comme "Loading"
|
||||
val initial = selectedIds.associateWith { LinkVerificationResult.Loading as LinkVerificationResult }
|
||||
_linkVerificationResults.value = initial
|
||||
|
||||
val links = linkDao.getLinksByIds(selectedIds.toList())
|
||||
for (link in links) {
|
||||
if (link.url.startsWith("note://") || link.url.startsWith("https://shaarit.app/note/")) {
|
||||
_linkVerificationResults.value = _linkVerificationResults.value + (link.id to LinkVerificationResult.Skipped)
|
||||
continue
|
||||
}
|
||||
val result = performLinkCheck(link.url)
|
||||
val verificationResult = when (result) {
|
||||
true -> {
|
||||
// Mettre à jour le statut en base
|
||||
linkDao.updateLinkHealthStatus(
|
||||
id = link.id,
|
||||
status = com.shaarit.data.local.entity.LinkCheckStatus.VALID,
|
||||
failCount = 0,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
LinkVerificationResult.Valid
|
||||
}
|
||||
false -> {
|
||||
val newFailCount = link.failCount + 1
|
||||
val newStatus = when {
|
||||
newFailCount >= 3 -> com.shaarit.data.local.entity.LinkCheckStatus.BROKEN
|
||||
else -> com.shaarit.data.local.entity.LinkCheckStatus.PENDING
|
||||
}
|
||||
linkDao.updateLinkHealthStatus(
|
||||
id = link.id,
|
||||
status = newStatus,
|
||||
failCount = newFailCount,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
LinkVerificationResult.Broken
|
||||
}
|
||||
}
|
||||
_linkVerificationResults.value = _linkVerificationResults.value + (link.id to verificationResult)
|
||||
}
|
||||
_isVerifyingLinks.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun clearVerificationResults() {
|
||||
_linkVerificationResults.value = emptyMap()
|
||||
}
|
||||
|
||||
private suspend fun performLinkCheck(urlStr: String): Boolean = withContext(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
|
||||
responseCode in 200..399 || responseCode == 401 || responseCode == 403 || responseCode == 405 || responseCode == 429
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class LinkVerificationResult {
|
||||
object Loading : LinkVerificationResult()
|
||||
object Valid : LinkVerificationResult()
|
||||
object Broken : LinkVerificationResult()
|
||||
object Skipped : LinkVerificationResult()
|
||||
}
|
||||
|
||||
@ -49,6 +49,9 @@ sealed class Screen(val route: String) {
|
||||
}
|
||||
object Reminders : Screen("reminders")
|
||||
object Todo : Screen("todo")
|
||||
object TodoDetail : Screen("todoDetail/{linkId}") {
|
||||
fun createRoute(linkId: Int): String = "todoDetail/$linkId"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -163,6 +166,7 @@ fun AppNavGraph(
|
||||
},
|
||||
onNavigateToReminders = { navController.navigate(Screen.Reminders.route) },
|
||||
onNavigateToTodo = { navController.navigate(Screen.Todo.route) },
|
||||
onNavigateToTodoDetail = { linkId -> navController.navigate(Screen.TodoDetail.createRoute(linkId)) },
|
||||
onPlayAudio = onPlayAudio,
|
||||
initialTagFilter = tag,
|
||||
initialCollectionId = collectionId
|
||||
@ -355,5 +359,20 @@ fun AppNavGraph(
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Screen.TodoDetail.route,
|
||||
arguments = listOf(
|
||||
navArgument("linkId") {
|
||||
type = NavType.IntType
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val linkId = backStackEntry.arguments?.getInt("linkId") ?: 0
|
||||
com.shaarit.presentation.todo.TodoDetailScreen(
|
||||
linkId = linkId,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,10 +29,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.FormatSize
|
||||
import androidx.compose.material.icons.filled.OpenInBrowser
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
@ -42,12 +44,16 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@ -79,10 +85,33 @@ fun ReaderModeScreen(
|
||||
) {
|
||||
val readerState by viewModel.readerState.collectAsState()
|
||||
val settings by viewModel.settings.collectAsState()
|
||||
val saveMarkdownState by viewModel.saveMarkdownState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
var showSettingsSheet by remember { mutableStateOf(false) }
|
||||
var showMarkdownConfirmDialog by remember { mutableStateOf(false) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Réagir au résultat de la sauvegarde markdown
|
||||
LaunchedEffect(saveMarkdownState) {
|
||||
when (val state = saveMarkdownState) {
|
||||
is SaveMarkdownState.Success -> {
|
||||
// Copier dans le presse-papier
|
||||
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
val clip = android.content.ClipData.newPlainText("Markdown", state.markdown)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
snackbarHostState.showSnackbar("Markdown copié et sauvegardé dans la description \u2705")
|
||||
viewModel.resetSaveState()
|
||||
}
|
||||
is SaveMarkdownState.Error -> {
|
||||
snackbarHostState.showSnackbar("Erreur : ${state.message}")
|
||||
viewModel.resetSaveState()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
@ -120,6 +149,25 @@ fun ReaderModeScreen(
|
||||
}
|
||||
if (readerState is ReaderState.Success) {
|
||||
val link = (readerState as ReaderState.Success).link
|
||||
// Bouton conversion Markdown
|
||||
IconButton(
|
||||
onClick = { showMarkdownConfirmDialog = true },
|
||||
enabled = saveMarkdownState !is SaveMarkdownState.Saving
|
||||
) {
|
||||
if (saveMarkdownState is SaveMarkdownState.Saving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.Description,
|
||||
contentDescription = "Convertir en Markdown",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
|
||||
context.startActivity(intent)
|
||||
@ -209,6 +257,34 @@ fun ReaderModeScreen(
|
||||
onThemeChange = viewModel::updateTheme
|
||||
)
|
||||
}
|
||||
|
||||
// Dialogue de confirmation pour la conversion Markdown
|
||||
if (showMarkdownConfirmDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showMarkdownConfirmDialog = false },
|
||||
title = { Text("Convertir en Markdown") },
|
||||
text = {
|
||||
Text(
|
||||
"Le contenu de l'article sera converti en format Markdown, copié dans le presse-papier et sauvegardé dans la description du bookmark.\n\nAttention : la description existante sera remplacée."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showMarkdownConfirmDialog = false
|
||||
viewModel.convertToMarkdownAndSave()
|
||||
}
|
||||
) {
|
||||
Text("Convertir", color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showMarkdownConfirmDialog = false }) {
|
||||
Text("Annuler")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed class ReaderState {
|
||||
@ -122,4 +124,239 @@ class ReaderModeViewModel @Inject constructor(
|
||||
fun updateFontSize(size: Float) = readerPreferences.updateFontSize(size)
|
||||
fun updateLineSpacing(spacing: Float) = readerPreferences.updateLineSpacing(spacing)
|
||||
fun updateTheme(theme: com.shaarit.data.reader.ReaderTheme) = readerPreferences.updateTheme(theme)
|
||||
|
||||
// ====== Conversion Markdown et sauvegarde dans la description ======
|
||||
|
||||
private val _saveMarkdownState = MutableStateFlow<SaveMarkdownState>(SaveMarkdownState.Idle)
|
||||
val saveMarkdownState: StateFlow<SaveMarkdownState> = _saveMarkdownState.asStateFlow()
|
||||
|
||||
fun convertToMarkdownAndSave() {
|
||||
val currentState = _readerState.value
|
||||
if (currentState !is ReaderState.Success) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_saveMarkdownState.value = SaveMarkdownState.Saving
|
||||
try {
|
||||
val article = currentState.article
|
||||
val link = currentState.link
|
||||
val markdown = htmlToMarkdown(article.content)
|
||||
|
||||
// Construire la description markdown complète avec le titre
|
||||
val fullMarkdown = buildString {
|
||||
appendLine("# ${article.title}")
|
||||
appendLine()
|
||||
article.author?.let {
|
||||
appendLine("*Par $it*")
|
||||
appendLine()
|
||||
}
|
||||
article.siteName?.let {
|
||||
appendLine("Source: $it")
|
||||
appendLine()
|
||||
}
|
||||
appendLine("---")
|
||||
appendLine()
|
||||
append(markdown)
|
||||
}
|
||||
|
||||
// Mettre à jour la description du lien en base de données
|
||||
linkDao.updateLink(
|
||||
link.copy(
|
||||
description = fullMarkdown,
|
||||
syncStatus = com.shaarit.data.local.entity.SyncStatus.PENDING_UPDATE,
|
||||
localModifiedAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
|
||||
_saveMarkdownState.value = SaveMarkdownState.Success(fullMarkdown)
|
||||
} catch (e: Exception) {
|
||||
_saveMarkdownState.value = SaveMarkdownState.Error(e.message ?: "Erreur inconnue")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetSaveState() {
|
||||
_saveMarkdownState.value = SaveMarkdownState.Idle
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit du HTML en Markdown de manière récursive.
|
||||
* Gère: titres, paragraphes, liens, images, code, listes, blockquotes, tables, emphasis.
|
||||
*/
|
||||
private suspend fun htmlToMarkdown(html: String): String = withContext(Dispatchers.Default) {
|
||||
val doc = org.jsoup.Jsoup.parseBodyFragment(html)
|
||||
val body = doc.body()
|
||||
convertElementToMarkdown(body).trim()
|
||||
}
|
||||
|
||||
private fun convertElementToMarkdown(element: org.jsoup.nodes.Element): String {
|
||||
val sb = StringBuilder()
|
||||
|
||||
for (node in element.childNodes()) {
|
||||
when (node) {
|
||||
is org.jsoup.nodes.TextNode -> {
|
||||
val text = node.wholeText
|
||||
// Ne pas ajouter de texte vide
|
||||
if (text.isNotBlank()) {
|
||||
sb.append(text.replace("\n", " ").trim())
|
||||
} else if (text.contains(" ") && sb.isNotEmpty() && !sb.endsWith(" ") && !sb.endsWith("\n")) {
|
||||
sb.append(" ")
|
||||
}
|
||||
}
|
||||
is org.jsoup.nodes.Element -> {
|
||||
val tag = node.tagName().lowercase()
|
||||
when (tag) {
|
||||
"h1" -> {
|
||||
sb.appendLine()
|
||||
sb.appendLine("# ${node.text()}")
|
||||
sb.appendLine()
|
||||
}
|
||||
"h2" -> {
|
||||
sb.appendLine()
|
||||
sb.appendLine("## ${node.text()}")
|
||||
sb.appendLine()
|
||||
}
|
||||
"h3" -> {
|
||||
sb.appendLine()
|
||||
sb.appendLine("### ${node.text()}")
|
||||
sb.appendLine()
|
||||
}
|
||||
"h4" -> {
|
||||
sb.appendLine()
|
||||
sb.appendLine("#### ${node.text()}")
|
||||
sb.appendLine()
|
||||
}
|
||||
"h5" -> {
|
||||
sb.appendLine()
|
||||
sb.appendLine("##### ${node.text()}")
|
||||
sb.appendLine()
|
||||
}
|
||||
"h6" -> {
|
||||
sb.appendLine()
|
||||
sb.appendLine("###### ${node.text()}")
|
||||
sb.appendLine()
|
||||
}
|
||||
"p" -> {
|
||||
sb.appendLine()
|
||||
sb.append(convertElementToMarkdown(node))
|
||||
sb.appendLine()
|
||||
sb.appendLine()
|
||||
}
|
||||
"br" -> {
|
||||
sb.appendLine(" ")
|
||||
}
|
||||
"strong", "b" -> {
|
||||
sb.append("**${convertElementToMarkdown(node).trim()}**")
|
||||
}
|
||||
"em", "i" -> {
|
||||
sb.append("*${convertElementToMarkdown(node).trim()}*")
|
||||
}
|
||||
"code" -> {
|
||||
if (node.parent()?.tagName()?.lowercase() == "pre") {
|
||||
// Handled by pre
|
||||
} else {
|
||||
sb.append("`${node.text()}`")
|
||||
}
|
||||
}
|
||||
"pre" -> {
|
||||
sb.appendLine()
|
||||
val codeEl = node.selectFirst("code")
|
||||
val codeContent = codeEl?.text() ?: node.text()
|
||||
val lang = codeEl?.className()?.let { cls ->
|
||||
Regex("language-(\\w+)").find(cls)?.groupValues?.get(1)
|
||||
} ?: ""
|
||||
sb.appendLine("```$lang")
|
||||
sb.appendLine(codeContent)
|
||||
sb.appendLine("```")
|
||||
sb.appendLine()
|
||||
}
|
||||
"a" -> {
|
||||
val href = node.attr("href")
|
||||
val text = convertElementToMarkdown(node).trim()
|
||||
if (href.isNotBlank() && text.isNotBlank()) {
|
||||
sb.append("[$text]($href)")
|
||||
} else {
|
||||
sb.append(text)
|
||||
}
|
||||
}
|
||||
"img" -> {
|
||||
val src = node.attr("src")
|
||||
val alt = node.attr("alt").ifBlank { "image" }
|
||||
if (src.isNotBlank()) {
|
||||
sb.appendLine()
|
||||
sb.appendLine("")
|
||||
sb.appendLine()
|
||||
}
|
||||
}
|
||||
"ul" -> {
|
||||
sb.appendLine()
|
||||
for (li in node.children()) {
|
||||
if (li.tagName().lowercase() == "li") {
|
||||
sb.appendLine("- ${convertElementToMarkdown(li).trim()}")
|
||||
}
|
||||
}
|
||||
sb.appendLine()
|
||||
}
|
||||
"ol" -> {
|
||||
sb.appendLine()
|
||||
var idx = 1
|
||||
for (li in node.children()) {
|
||||
if (li.tagName().lowercase() == "li") {
|
||||
sb.appendLine("$idx. ${convertElementToMarkdown(li).trim()}")
|
||||
idx++
|
||||
}
|
||||
}
|
||||
sb.appendLine()
|
||||
}
|
||||
"blockquote" -> {
|
||||
sb.appendLine()
|
||||
val content = convertElementToMarkdown(node).trim()
|
||||
content.lines().forEach { line ->
|
||||
sb.appendLine("> $line")
|
||||
}
|
||||
sb.appendLine()
|
||||
}
|
||||
"table" -> {
|
||||
sb.appendLine()
|
||||
val rows = node.select("tr")
|
||||
for ((rowIdx, row) in rows.withIndex()) {
|
||||
val cells = row.select("th, td")
|
||||
sb.appendLine("| ${cells.joinToString(" | ") { it.text() }} |")
|
||||
if (rowIdx == 0) {
|
||||
sb.appendLine("| ${cells.joinToString(" | ") { "---" }} |")
|
||||
}
|
||||
}
|
||||
sb.appendLine()
|
||||
}
|
||||
"hr" -> {
|
||||
sb.appendLine()
|
||||
sb.appendLine("---")
|
||||
sb.appendLine()
|
||||
}
|
||||
"figure" -> {
|
||||
sb.append(convertElementToMarkdown(node))
|
||||
}
|
||||
"figcaption" -> {
|
||||
sb.appendLine("*${node.text()}*")
|
||||
sb.appendLine()
|
||||
}
|
||||
"div", "section", "article", "main", "span" -> {
|
||||
sb.append(convertElementToMarkdown(node))
|
||||
}
|
||||
else -> {
|
||||
sb.append(convertElementToMarkdown(node))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
|
||||
sealed class SaveMarkdownState {
|
||||
object Idle : SaveMarkdownState()
|
||||
object Saving : SaveMarkdownState()
|
||||
data class Success(val markdown: String) : SaveMarkdownState()
|
||||
data class Error(val message: String) : SaveMarkdownState()
|
||||
}
|
||||
|
||||
@ -0,0 +1,106 @@
|
||||
package com.shaarit.presentation.todo
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.shaarit.domain.model.SubTask
|
||||
import com.shaarit.domain.model.TodoItem
|
||||
import com.shaarit.presentation.todo.TodoState
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TodoDetailScreen(
|
||||
linkId: Int,
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: TodoDetailViewModel = hiltViewModel()
|
||||
) {
|
||||
LaunchedEffect(linkId) {
|
||||
viewModel.loadTodo(linkId)
|
||||
}
|
||||
val todoState by viewModel.todoState.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Détails de la tâche") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Retour")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
when (val state = todoState) {
|
||||
is TodoState.Loading -> {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||
}
|
||||
is TodoState.Error -> {
|
||||
Text(text = "Erreur: ${state.message}", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
is TodoState.Success -> {
|
||||
val todo = state.todo
|
||||
Text(
|
||||
text = todo.content,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
if (todo.tags.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Tags: ${todo.tags.joinToString()}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
}
|
||||
if (todo.dueDate != null) {
|
||||
Text(
|
||||
text = "Échéance: ${todo.dueDate}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
}
|
||||
if (todo.subtasks.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Sous-tâches:",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
todo.subtasks.forEach { subtask ->
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Checkbox(checked = subtask.isDone, onCheckedChange = null)
|
||||
Text(text = subtask.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is TodoState.NotFound -> {
|
||||
Text(text = "Tâche non trouvée")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
package com.shaarit.presentation.todo
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.shaarit.domain.model.ShaarliLink
|
||||
import com.shaarit.domain.model.TodoItem
|
||||
import com.shaarit.domain.repository.LinkRepository
|
||||
import com.shaarit.domain.repository.TodoRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed class TodoState {
|
||||
object Loading : TodoState()
|
||||
data class Success(val todo: TodoItem, val link: ShaarliLink?) : TodoState()
|
||||
data class Error(val message: String) : TodoState()
|
||||
object NotFound : TodoState()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class TodoDetailViewModel @Inject constructor(
|
||||
private val linkRepository: LinkRepository,
|
||||
private val todoRepository: TodoRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _todoState = MutableStateFlow<TodoState>(TodoState.Loading)
|
||||
val todoState: StateFlow<TodoState> = _todoState.asStateFlow()
|
||||
|
||||
fun loadTodo(linkId: Int) {
|
||||
_todoState.value = TodoState.Loading
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val linkResult = linkRepository.getLink(linkId)
|
||||
if (linkResult.isFailure) {
|
||||
_todoState.value = TodoState.Error("Lien non trouvé")
|
||||
return@launch
|
||||
}
|
||||
val link = linkResult.getOrThrow()
|
||||
// Chercher le todo via l'URL du lien
|
||||
val todo = todoRepository.getTodoByShaarliLinkUrl(link.url)
|
||||
if (todo == null) {
|
||||
_todoState.value = TodoState.NotFound
|
||||
} else {
|
||||
_todoState.value = TodoState.Success(todo, link)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_todoState.value = TodoState.Error(e.message ?: "Erreur inconnue")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,3 @@
|
||||
#Sun Feb 22 21:42:41 2026
|
||||
VERSION_NAME=2.7.0
|
||||
VERSION_CODE=30
|
||||
#Wed Apr 22 21:45:20 2026
|
||||
VERSION_NAME=2.9.0
|
||||
VERSION_CODE=34
|
||||
Loading…
x
Reference in New Issue
Block a user