From 83658cc6c2b4e002e7fdaf0b9ec522cb62ff060a Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 22 Apr 2026 21:57:17 -0400 Subject: [PATCH] feat: implement core app screens, viewmodels, and repository layer for link management and navigation --- .../com/shaarit/data/local/dao/LinkDao.kt | 4 + .../data/repository/TodoRepositoryImpl.kt | 4 + .../data/worker/LinkHealthCheckWorker.kt | 1 + .../java/com/shaarit/domain/model/Models.kt | 19 +- .../domain/repository/TodoRepository.kt | 2 + .../presentation/add/AddLinkViewModel.kt | 4 +- .../presentation/edit/EditLinkViewModel.kt | 4 +- .../shaarit/presentation/feed/FeedScreen.kt | 171 +++++++++++-- .../presentation/feed/FeedViewModel.kt | 88 +++++++ .../com/shaarit/presentation/nav/NavGraph.kt | 19 ++ .../presentation/reader/ReaderModeScreen.kt | 76 ++++++ .../reader/ReaderModeViewModel.kt | 237 ++++++++++++++++++ .../presentation/todo/TodoDetailScreen.kt | 106 ++++++++ .../presentation/todo/TodoDetailViewModel.kt | 54 ++++ version.properties | 6 +- 15 files changed, 769 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/shaarit/presentation/todo/TodoDetailScreen.kt create mode 100644 app/src/main/java/com/shaarit/presentation/todo/TodoDetailViewModel.kt diff --git a/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt b/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt index 627e75a..c5e60c8 100644 --- a/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt +++ b/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt @@ -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): List @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 diff --git a/app/src/main/java/com/shaarit/data/repository/TodoRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/TodoRepositoryImpl.kt index 356b2b0..ab3ebe9 100644 --- a/app/src/main/java/com/shaarit/data/repository/TodoRepositoryImpl.kt +++ b/app/src/main/java/com/shaarit/data/repository/TodoRepositoryImpl.kt @@ -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 { return runCatching { val normalized = normalizeTodo(todo) diff --git a/app/src/main/java/com/shaarit/data/worker/LinkHealthCheckWorker.kt b/app/src/main/java/com/shaarit/data/worker/LinkHealthCheckWorker.kt index 7d06ce1..fbf603a 100644 --- a/app/src/main/java/com/shaarit/data/worker/LinkHealthCheckWorker.kt +++ b/app/src/main/java/com/shaarit/data/worker/LinkHealthCheckWorker.kt @@ -40,6 +40,7 @@ class LinkHealthCheckWorker @AssistedInject constructor( "note://", "http://shaare", "/shaare", + "https://shaarit.app/note/", "file://", "localhost", "127.0.0.1", diff --git a/app/src/main/java/com/shaarit/domain/model/Models.kt b/app/src/main/java/com/shaarit/domain/model/Models.kt index 177fa72..42a53a2 100644 --- a/app/src/main/java/com/shaarit/domain/model/Models.kt +++ b/app/src/main/java/com/shaarit/domain/model/Models.kt @@ -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 */ diff --git a/app/src/main/java/com/shaarit/domain/repository/TodoRepository.kt b/app/src/main/java/com/shaarit/domain/repository/TodoRepository.kt index 2023d1f..af22156 100644 --- a/app/src/main/java/com/shaarit/domain/repository/TodoRepository.kt +++ b/app/src/main/java/com/shaarit/domain/repository/TodoRepository.kt @@ -10,6 +10,8 @@ interface TodoRepository { suspend fun getTodoById(id: Long): TodoItem? + suspend fun getTodoByShaarliLinkUrl(url: String): TodoItem? + suspend fun upsertTodo(todo: TodoItem): Result suspend fun toggleDone(todoId: Long, isDone: Boolean): Result diff --git a/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt b/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt index caa5604..07a9a83 100644 --- a/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt @@ -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" diff --git a/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt b/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt index 1fdccc7..1a0a5e1 100644 --- a/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt @@ -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" diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt index 1b5548e..9aad068 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt @@ -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()) } 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( diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt index 71856d8..c587380 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt @@ -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( val first: A, @@ -228,4 +230,90 @@ class FeedViewModel @Inject constructor( _refreshTrigger.value++ } } + + // ====== Vérification de liens sélectionnés ====== + + private val _linkVerificationResults = MutableStateFlow>(emptyMap()) + val linkVerificationResults = _linkVerificationResults.asStateFlow() + + private val _isVerifyingLinks = MutableStateFlow(false) + val isVerifyingLinks = _isVerifyingLinks.asStateFlow() + + fun verifySelectedLinks(selectedIds: Set) { + 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() } diff --git a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt index 912fd9e..06219f7 100644 --- a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt +++ b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt @@ -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() } + ) + } } } diff --git a/app/src/main/java/com/shaarit/presentation/reader/ReaderModeScreen.kt b/app/src/main/java/com/shaarit/presentation/reader/ReaderModeScreen.kt index 2bfe018..7318813 100644 --- a/app/src/main/java/com/shaarit/presentation/reader/ReaderModeScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/reader/ReaderModeScreen.kt @@ -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 diff --git a/app/src/main/java/com/shaarit/presentation/reader/ReaderModeViewModel.kt b/app/src/main/java/com/shaarit/presentation/reader/ReaderModeViewModel.kt index b941f42..0f52793 100644 --- a/app/src/main/java/com/shaarit/presentation/reader/ReaderModeViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/reader/ReaderModeViewModel.kt @@ -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.Idle) + val saveMarkdownState: StateFlow = _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() } diff --git a/app/src/main/java/com/shaarit/presentation/todo/TodoDetailScreen.kt b/app/src/main/java/com/shaarit/presentation/todo/TodoDetailScreen.kt new file mode 100644 index 0000000..2729a23 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/todo/TodoDetailScreen.kt @@ -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") + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/shaarit/presentation/todo/TodoDetailViewModel.kt b/app/src/main/java/com/shaarit/presentation/todo/TodoDetailViewModel.kt new file mode 100644 index 0000000..198cb0d --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/todo/TodoDetailViewModel.kt @@ -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.Loading) + val todoState: StateFlow = _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") + } + } + } +} \ No newline at end of file diff --git a/version.properties b/version.properties index 129c702..152e211 100644 --- a/version.properties +++ b/version.properties @@ -1,3 +1,3 @@ -#Sun Feb 22 21:42:41 2026 -VERSION_NAME=2.7.0 -VERSION_CODE=30 \ No newline at end of file +#Wed Apr 22 21:45:20 2026 +VERSION_NAME=2.9.0 +VERSION_CODE=34 \ No newline at end of file