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

@ -31,6 +31,9 @@ interface LinkDao {
@Query("SELECT * FROM links WHERE id = :id") @Query("SELECT * FROM links WHERE id = :id")
suspend fun getLinkById(id: Int): LinkEntity? 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") @Query("SELECT * FROM links WHERE url = :url")
suspend fun getLinkByUrl(url: String): LinkEntity? suspend fun getLinkByUrl(url: String): LinkEntity?
@ -116,6 +119,7 @@ interface LinkDao {
@Query(""" @Query("""
SELECT * FROM links SELECT * FROM links
WHERE url NOT LIKE 'note://%' WHERE url NOT LIKE 'note://%'
AND url NOT LIKE 'https://shaarit.app/note/%'
AND excluded_from_health_check = 0 AND excluded_from_health_check = 0
AND (last_health_check < :timestamp OR last_health_check IS NULL) AND (last_health_check < :timestamp OR last_health_check IS NULL)
ORDER BY last_health_check ASC ORDER BY last_health_check ASC

View File

@ -48,6 +48,10 @@ class TodoRepositoryImpl @Inject constructor(
return todoDao.getTodoById(id)?.toDomain() 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> { override suspend fun upsertTodo(todo: TodoItem): Result<Long> {
return runCatching { return runCatching {
val normalized = normalizeTodo(todo) val normalized = normalizeTodo(todo)

View File

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

View File

@ -28,7 +28,7 @@ data class ShaarliLink(
*/ */
val healthStatus: HealthStatus val healthStatus: HealthStatus
get() = when { 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 lastHealthCheck == 0L -> HealthStatus.UNTESTED
linkCheckStatus == LinkCheckStatus.BROKEN -> HealthStatus.DEAD linkCheckStatus == LinkCheckStatus.BROKEN -> HealthStatus.DEAD
linkCheckStatus == LinkCheckStatus.PENDING -> HealthStatus.PENDING 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 val isNote: Boolean
get() = url.startsWith("note://") || get() = (url.startsWith("note://") && !url.startsWith("note://todo-")) ||
url.startsWith("http://shaare") || url.startsWith("http://shaare") ||
url.startsWith("/shaare") || url.startsWith("/shaare") ||
url.startsWith("https://shaarit.app/note/") ||
tags.any { it.lowercase() == "note" || it.lowercase() == "#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 * 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 getTodoById(id: Long): TodoItem?
suspend fun getTodoByShaarliLinkUrl(url: String): TodoItem?
suspend fun upsertTodo(todo: TodoItem): Result<Long> suspend fun upsertTodo(todo: TodoItem): Result<Long>
suspend fun toggleDone(todoId: Long, isDone: Boolean): Result<Unit> suspend fun toggleDone(todoId: Long, isDone: Boolean): Result<Unit>

View File

@ -323,7 +323,7 @@ constructor(
if (_contentTypeSelection.value == ContentType.NOTE) { if (_contentTypeSelection.value == ContentType.NOTE) {
if (finalUrl.isBlank()) { if (finalUrl.isBlank()) {
finalUrl = "/shaare/${generateRandomId()}" finalUrl = "https://shaarit.app/note/${generateRandomId()}"
} }
if (finalTitle.isNotBlank() && !finalTitle.startsWith("Note: ", ignoreCase = true)) { if (finalTitle.isNotBlank() && !finalTitle.startsWith("Note: ", ignoreCase = true)) {
finalTitle = "Note: $finalTitle" finalTitle = "Note: $finalTitle"
@ -388,7 +388,7 @@ constructor(
if (_contentTypeSelection.value == ContentType.NOTE) { if (_contentTypeSelection.value == ContentType.NOTE) {
if (finalUrl.isBlank()) { if (finalUrl.isBlank()) {
finalUrl = "/shaare/${generateRandomId()}" finalUrl = "https://shaarit.app/note/${generateRandomId()}"
} }
if (finalTitle.isNotBlank() && !finalTitle.startsWith("Note: ", ignoreCase = true)) { if (finalTitle.isNotBlank() && !finalTitle.startsWith("Note: ", ignoreCase = true)) {
finalTitle = "Note: $finalTitle" finalTitle = "Note: $finalTitle"

View File

@ -89,7 +89,7 @@ constructor(
originalTags = link.tags originalTags = link.tags
// Détecter si c'est une note // 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 _contentType.value = if (isNote) ContentType.NOTE else ContentType.BOOKMARK
_uiState.value = EditLinkUiState.Loaded _uiState.value = EditLinkUiState.Loaded
@ -329,7 +329,7 @@ constructor(
if (_contentType.value == ContentType.NOTE) { if (_contentType.value == ContentType.NOTE) {
if (finalUrl.isBlank()) { if (finalUrl.isBlank()) {
finalUrl = "/shaare/${generateRandomId()}" finalUrl = "https://shaarit.app/note/${generateRandomId()}"
} }
if (finalTitle.isNotBlank() && !finalTitle.startsWith("Note: ", ignoreCase = true)) { if (finalTitle.isNotBlank() && !finalTitle.startsWith("Note: ", ignoreCase = true)) {
finalTitle = "Note: $finalTitle" finalTitle = "Note: $finalTitle"

View File

@ -284,6 +284,7 @@ fun FeedScreen(
onNavigateToReader: (Int) -> Unit = {}, onNavigateToReader: (Int) -> Unit = {},
onNavigateToReminders: () -> Unit = {}, onNavigateToReminders: () -> Unit = {},
onNavigateToTodo: () -> Unit = {}, onNavigateToTodo: () -> Unit = {},
onNavigateToTodoDetail: (Int) -> Unit = {},
onPlayAudio: ((com.shaarit.domain.model.ShaarliLink) -> Unit)? = null, onPlayAudio: ((com.shaarit.domain.model.ShaarliLink) -> Unit)? = null,
initialTagFilter: String? = null, initialTagFilter: String? = null,
initialCollectionId: Long? = null, initialCollectionId: Long? = null,
@ -310,6 +311,9 @@ fun FeedScreen(
var selectionMode by remember { mutableStateOf(false) } var selectionMode by remember { mutableStateOf(false) }
var selectedIds by remember { mutableStateOf(setOf<Int>()) } var selectedIds by remember { mutableStateOf(setOf<Int>()) }
var showAddToCollectionDialog by remember { mutableStateOf(false) } 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 // Reminder bottom sheet state
var showReminderSheet by remember { mutableStateOf(false) } var showReminderSheet by remember { mutableStateOf(false) }
@ -318,6 +322,25 @@ fun FeedScreen(
val reminderViewModel: com.shaarit.presentation.reminders.ReminderViewModel = hiltViewModel() val reminderViewModel: com.shaarit.presentation.reminders.ReminderViewModel = hiltViewModel()
val linkIdsWithReminders by reminderViewModel.linkIdsWithReminders.collectAsState() 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) // États des accordéons du drawer (accordion: un seul ouvert à la fois)
var expandedSection by remember { mutableStateOf("main") } var expandedSection by remember { mutableStateOf("main") }
@ -822,6 +845,19 @@ fun FeedScreen(
tint = if (selectedIds.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline 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( IconButton(
onClick = { onClick = {
selectedIds = emptySet() selectedIds = emptySet()
@ -1466,8 +1502,7 @@ fun FeedScreen(
if (selectedIds.contains(link.id)) selectedIds - link.id if (selectedIds.contains(link.id)) selectedIds - link.id
else selectedIds + link.id else selectedIds + link.id
} else { } else {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url)) handleLinkClick(link)
context.startActivity(intent)
} }
}, },
onItemLongClick = { onItemLongClick = {
@ -1484,8 +1519,7 @@ fun FeedScreen(
selectionMode = selectionMode, selectionMode = selectionMode,
isSelected = selectedIds.contains(link.id), isSelected = selectedIds.contains(link.id),
onLinkClick = { url -> onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) handleLinkClick(link)
context.startActivity(intent)
}, },
onViewClick = { selectedLink = link }, onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit, onEditClick = onNavigateToEdit,
@ -1529,8 +1563,7 @@ fun FeedScreen(
if (selectedIds.contains(link.id)) selectedIds - link.id if (selectedIds.contains(link.id)) selectedIds - link.id
else selectedIds + link.id else selectedIds + link.id
} else { } else {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url)) handleLinkClick(link)
context.startActivity(intent)
} }
}, },
onItemLongClick = { onItemLongClick = {
@ -1547,8 +1580,7 @@ fun FeedScreen(
selectionMode = selectionMode, selectionMode = selectionMode,
isSelected = selectedIds.contains(link.id), isSelected = selectedIds.contains(link.id),
onLinkClick = { url -> onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) handleLinkClick(link)
context.startActivity(intent)
}, },
onViewClick = { selectedLink = link }, onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit, onEditClick = onNavigateToEdit,
@ -1592,8 +1624,7 @@ fun FeedScreen(
if (selectedIds.contains(link.id)) selectedIds - link.id if (selectedIds.contains(link.id)) selectedIds - link.id
else selectedIds + link.id else selectedIds + link.id
} else { } else {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url)) handleLinkClick(link)
context.startActivity(intent)
} }
}, },
onItemLongClick = { onItemLongClick = {
@ -1611,8 +1642,7 @@ fun FeedScreen(
isSelected = selectedIds.contains(link.id), isSelected = selectedIds.contains(link.id),
onTagClick = viewModel::onTagClicked, onTagClick = viewModel::onTagClicked,
onLinkClick = { url -> onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) handleLinkClick(link)
context.startActivity(intent)
}, },
onViewClick = { selectedLink = link }, onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit, onEditClick = onNavigateToEdit,
@ -1666,8 +1696,7 @@ fun FeedScreen(
link = link, link = link,
onDismiss = { selectedLink = null }, onDismiss = { selectedLink = null },
onLinkClick = { url -> onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) handleLinkClick(link)
context.startActivity(intent)
}, },
onReadClick = { linkId -> onReadClick = { linkId ->
onNavigateToReader(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 // Reminder Bottom Sheet
if (showReminderSheet && reminderTargetLinkId > 0) { if (showReminderSheet && reminderTargetLinkId > 0) {
com.shaarit.presentation.reminders.ReminderBottomSheet( 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.stateIn
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
data class Quadruple<A, B, C, D>( data class Quadruple<A, B, C, D>(
val first: A, val first: A,
@ -228,4 +230,90 @@ class FeedViewModel @Inject constructor(
_refreshTrigger.value++ _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 Reminders : Screen("reminders")
object Todo : Screen("todo") object Todo : Screen("todo")
object TodoDetail : Screen("todoDetail/{linkId}") {
fun createRoute(linkId: Int): String = "todoDetail/$linkId"
}
} }
@Composable @Composable
@ -163,6 +166,7 @@ fun AppNavGraph(
}, },
onNavigateToReminders = { navController.navigate(Screen.Reminders.route) }, onNavigateToReminders = { navController.navigate(Screen.Reminders.route) },
onNavigateToTodo = { navController.navigate(Screen.Todo.route) }, onNavigateToTodo = { navController.navigate(Screen.Todo.route) },
onNavigateToTodoDetail = { linkId -> navController.navigate(Screen.TodoDetail.createRoute(linkId)) },
onPlayAudio = onPlayAudio, onPlayAudio = onPlayAudio,
initialTagFilter = tag, initialTagFilter = tag,
initialCollectionId = collectionId initialCollectionId = collectionId
@ -355,5 +359,20 @@ fun AppNavGraph(
onNavigateBack = { navController.popBackStack() } 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.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack 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.FormatSize
import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
@ -42,12 +44,16 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -79,10 +85,33 @@ fun ReaderModeScreen(
) { ) {
val readerState by viewModel.readerState.collectAsState() val readerState by viewModel.readerState.collectAsState()
val settings by viewModel.settings.collectAsState() val settings by viewModel.settings.collectAsState()
val saveMarkdownState by viewModel.saveMarkdownState.collectAsState()
val context = LocalContext.current val context = LocalContext.current
var showSettingsSheet by remember { mutableStateOf(false) } 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( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
@ -120,6 +149,25 @@ fun ReaderModeScreen(
} }
if (readerState is ReaderState.Success) { if (readerState is ReaderState.Success) {
val link = (readerState as ReaderState.Success).link 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 = { IconButton(onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url)) val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
context.startActivity(intent) context.startActivity(intent)
@ -209,6 +257,34 @@ fun ReaderModeScreen(
onThemeChange = viewModel::updateTheme 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 @Composable

View File

@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
sealed class ReaderState { sealed class ReaderState {
@ -122,4 +124,239 @@ class ReaderModeViewModel @Inject constructor(
fun updateFontSize(size: Float) = readerPreferences.updateFontSize(size) fun updateFontSize(size: Float) = readerPreferences.updateFontSize(size)
fun updateLineSpacing(spacing: Float) = readerPreferences.updateLineSpacing(spacing) fun updateLineSpacing(spacing: Float) = readerPreferences.updateLineSpacing(spacing)
fun updateTheme(theme: com.shaarit.data.reader.ReaderTheme) = readerPreferences.updateTheme(theme) 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 #Wed Apr 22 21:45:20 2026
VERSION_NAME=2.7.0 VERSION_NAME=2.9.0
VERSION_CODE=30 VERSION_CODE=34