feat: implement core app screens, viewmodels, and repository layer for link management and navigation

This commit is contained in:
Bruno Charest 2026-04-22 21:57:17 -04:00
parent 9479986e33
commit 83658cc6c2
15 changed files with 769 additions and 26 deletions

View File

@ -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

View File

@ -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)

View File

@ -40,6 +40,7 @@ class LinkHealthCheckWorker @AssistedInject constructor(
"note://",
"http://shaare",
"/shaare",
"https://shaarit.app/note/",
"file://",
"localhost",
"127.0.0.1",

View File

@ -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
*/

View File

@ -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>

View File

@ -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"

View File

@ -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"

View File

@ -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(

View File

@ -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()
}

View File

@ -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() }
)
}
}
}

View File

@ -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

View File

@ -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("![$alt]($src)")
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()
}

View File

@ -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")
}
}
}
}
}

View File

@ -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")
}
}
}
}

View File

@ -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