diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt index 7376858..30cc303 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt @@ -13,14 +13,23 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -44,7 +53,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView @@ -52,6 +63,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -61,7 +73,9 @@ import com.vaultdrop.mobile.R import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.domain.FileCategory import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel +import com.vaultdrop.mobile.features.saf.FileDeleter import com.vaultdrop.mobile.features.sync.SyncViewModel +import com.vaultdrop.mobile.ui.components.DeleteConfirmDialog import com.vaultdrop.mobile.ui.components.ServerStatusBadge import com.vaultdrop.mobile.ui.components.SyncStatusAction import com.vaultdrop.mobile.ui.components.categoryValue @@ -96,6 +110,7 @@ fun DocumentViewerScreen( viewModel: DocumentViewerViewModel = hiltViewModel(), ) { val documents by viewModel.documents.collectAsStateWithLifecycle() + val viewerState by viewModel.uiState.collectAsStateWithLifecycle() val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() val pagerState = rememberPagerState(pageCount = { documents.size }) @@ -108,6 +123,9 @@ fun DocumentViewerScreen( pagerState.scrollToPage(index) jumpPending = false } + } else if (documents.isEmpty()) { + // Dernier document supprimé : plus rien à afficher → retour. + onBack() } } @@ -122,6 +140,24 @@ fun DocumentViewerScreen( } } + // Erreurs / succès transitoires (suppression, garder) → Snackbar puis effacés. + LaunchedEffect(viewerState.deleteError) { + viewerState.deleteError?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearDeleteError() + } + } + LaunchedEffect(viewerState.keepMessage) { + viewerState.keepMessage?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearKeepMessage() + } + } + + // Confirmation avant suppression d'un document déjà traité. + var pendingDeleteMode by remember { mutableStateOf(null) } + var showDeleteConfirm by remember { mutableStateOf(false) } + var fullscreen by rememberSaveable { mutableStateOf(false) } val canFullscreen = currentFile?.canFocus() == true @@ -193,25 +229,63 @@ fun DocumentViewerScreen( modifier = Modifier.fillMaxSize(), ) } else { - HorizontalPager( - state = pagerState, + Column( modifier = Modifier .fillMaxSize() .padding(padding), - ) { page -> - val file = documents.getOrNull(page) - if (file != null) { - DocumentViewerPage( - file = file, - onOpenExternalFailed = onOpenExternalFailed, - onEnterFullscreen = file.canFocus() - .takeIf { it } - ?.let { { fullscreen = true } }, + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { page -> + val file = documents.getOrNull(page) + if (file != null) { + DocumentViewerPage( + file = file, + onOpenExternalFailed = onOpenExternalFailed, + onEnterFullscreen = file.canFocus() + .takeIf { it } + ?.let { { fullscreen = true } }, + ) + } + } + // Barre d'action fixe pilotée par le document affiché : garder + // ou supprimer un document en attente de review, ou supprimer + // (local / cloud / les deux) un document déjà traité. + if (current != null) { + DocumentActionBar( + file = current, + busy = viewerState.busy, + onKeep = { viewModel.keep(current) }, + onDeleteModeSelected = { mode -> + pendingDeleteMode = mode + showDeleteConfirm = true + }, + modifier = Modifier.fillMaxWidth(), ) } } } } + + if (showDeleteConfirm && pendingDeleteMode != null && currentFile != null) { + DeleteConfirmDialog( + count = 1, + onConfirm = { + val mode = pendingDeleteMode!! + val file = currentFile!! + showDeleteConfirm = false + pendingDeleteMode = null + viewModel.delete(file, mode) + }, + onDismiss = { + showDeleteConfirm = false + pendingDeleteMode = null + }, + ) + } } /** @@ -432,6 +506,199 @@ private fun DocumentViewerPage( } } +/** + * Barre d'action fixe du document affiché. + * + * - Document en attente de review (`processed = false`, fichier physique) : + * badge « À traiter » + boutons GARDER / SUPPRIMER (même sémantique que le + * mode swipe : garder pousse le `create_resource`, supprimer efface le fichier). + * - Document déjà traité : bouton « Supprimer » dont les options (local, cloud, + * les deux) sont conditionnées au placement du fichier ([deleteModesFor]). + */ +@Composable +private fun DocumentActionBar( + file: FileEntity, + busy: Boolean, + onKeep: () -> Unit, + onDeleteModeSelected: (FileDeleter.DeleteMode) -> Unit, + modifier: Modifier = Modifier, +) { + val pendingReview = !file.processed && file.uri != null + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp, + modifier = modifier, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (pendingReview) { + Surface( + shape = RoundedCornerShape(percent = 50), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = stringResource(R.string.file_pending_review), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + } + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) { + DocumentActionButton( + icon = Icons.Filled.Close, + contentDescription = stringResource(R.string.review_delete), + label = stringResource(R.string.review_delete), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + enabled = !busy, + onClick = { onDeleteModeSelected(FileDeleter.DeleteMode.LOCALLY) }, + ) + DocumentActionButton( + icon = Icons.Filled.Check, + contentDescription = stringResource(R.string.review_keep), + label = stringResource(R.string.review_keep), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + enabled = !busy, + onClick = onKeep, + ) + } + } else { + DocumentDeleteMenu( + file = file, + enabled = !busy, + onModeSelected = onDeleteModeSelected, + ) + } + } + } +} + +/** Bouton rond d'action (garder / supprimer), style mode review mais compact. */ +@Composable +private fun DocumentActionButton( + icon: ImageVector, + contentDescription: String, + label: String, + containerColor: Color, + contentColor: Color, + enabled: Boolean, + onClick: () -> Unit, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + FloatingActionButton( + onClick = { if (enabled) onClick() }, + shape = CircleShape, + containerColor = containerColor, + contentColor = contentColor, + modifier = Modifier + .size(56.dp) + .alpha(if (enabled) 1f else 0.38f), + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.size(24.dp), + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } +} + +/** + * Bouton « Supprimer ▼ » avec dropdown ne proposant que les modes compatibles + * avec le placement du fichier (une seule entrée pour un fichier purement + * local ou cloud-only). + */ +@Composable +private fun DocumentDeleteMenu( + file: FileEntity, + enabled: Boolean, + onModeSelected: (FileDeleter.DeleteMode) -> Unit, + modifier: Modifier = Modifier, +) { + val modes = deleteModesFor(file) + var expanded by remember { mutableStateOf(false) } + val tint = if (enabled) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant + + Box(modifier = modifier) { + Surface( + onClick = { if (enabled) expanded = true }, + enabled = enabled, + shape = RoundedCornerShape(20.dp), + color = if (enabled) MaterialTheme.colorScheme.errorContainer + else MaterialTheme.colorScheme.surfaceVariant, + ) { + Box( + modifier = Modifier.size(width = 88.dp, height = 48.dp), + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null, + tint = tint, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(R.string.delete), + color = tint, + fontSize = 11.sp, + maxLines = 1, + fontWeight = FontWeight.Bold, + ) + Icon( + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = null, + tint = tint, + modifier = Modifier.size(16.dp), + ) + } + } + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + modes.forEach { mode -> + DropdownMenuItem( + text = { Text(deleteModeLabel(mode)) }, + onClick = { + expanded = false + onModeSelected(mode) + }, + leadingIcon = { + Icon( + Icons.Filled.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + ) + } + } + } +} + +@Composable +private fun deleteModeLabel(mode: FileDeleter.DeleteMode): String = when (mode) { + FileDeleter.DeleteMode.LOCALLY -> stringResource(R.string.delete_locally) + FileDeleter.DeleteMode.IN_CLOUD -> stringResource(R.string.delete_in_cloud) + FileDeleter.DeleteMode.FULL -> stringResource(R.string.delete_full) +} + @Composable private fun MetadataRow(label: String, value: String) { Row( diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerViewModel.kt index ed73c90..d78d304 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerViewModel.kt @@ -1,29 +1,53 @@ package com.vaultdrop.mobile.ui.document +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vaultdrop.mobile.R import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.data.local.entity.FileStatus import com.vaultdrop.mobile.data.local.orderedByReferenceDateDesc import com.vaultdrop.mobile.data.repository.FileRepository +import com.vaultdrop.mobile.features.saf.FileDeleter import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject /** * Consultation de documents : expose la liste complète des fichiers (même tri * que l'accueil) pour naviguer au swipe d'un document à l'autre. + * + * Porte aussi les actions du document affiché : garder un fichier de la review + * (`processed = false`) ou le supprimer selon les trois modes [FileDeleter.DeleteMode]. */ @HiltViewModel class DocumentViewerViewModel @Inject constructor( private val fileRepository: FileRepository, + private val fileDeleter: FileDeleter, + @ApplicationContext private val context: Context, ) : ViewModel() { + data class ViewerUiState( + /** Une action (garder / supprimer) est en cours — désactive les boutons. */ + val busy: Boolean = false, + /** Erreur transitoire de suppression — affichée en Snackbar puis effacée. */ + val deleteError: String? = null, + /** Succès transitoire « gardé » — affiché en Snackbar puis effacé. */ + val keepMessage: String? = null, + ) + private val _documents = MutableStateFlow>(emptyList()) val documents: StateFlow> = _documents.asStateFlow() + private val _uiState = MutableStateFlow(ViewerUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + init { viewModelScope.launch { fileRepository.observeAllVisible().collect { files -> @@ -31,4 +55,58 @@ class DocumentViewerViewModel @Inject constructor( } } } + + /** Garder un document en attente de review : il est marqué traité et poussé. */ + fun keep(file: FileEntity) { + _uiState.update { it.copy(busy = true) } + viewModelScope.launch { + runCatching { fileRepository.markProcessed(file.resourceId) } + .onSuccess { + _uiState.update { it.copy(keepMessage = context.getString(R.string.document_keep_done)) } + } + .onFailure { + Timber.w(it, "document: mark processed failed for %s", file.resourceId) + _uiState.update { it.copy(deleteError = context.getString(R.string.document_keep_error)) } + } + _uiState.update { it.copy(busy = false) } + } + } + + /** Supprime le document selon le mode choisi (local, cloud, ou les deux). */ + fun delete(file: FileEntity, mode: FileDeleter.DeleteMode) { + _uiState.update { it.copy(busy = true) } + viewModelScope.launch { + val report = fileDeleter.deleteFiles(listOf(file), mode) + if (report.failed > 0) { + _uiState.update { it.copy(deleteError = context.getString(R.string.delete_error)) } + } + _uiState.update { it.copy(busy = false) } + } + } + + fun clearDeleteError() { + _uiState.update { it.copy(deleteError = null) } + } + + fun clearKeepMessage() { + _uiState.update { it.copy(keepMessage = null) } + } +} + +/** + * Modes de suppression possibles pour un fichier, selon son placement. + * + * - `local` : pas de copie cloud → seul un delete physique a un sens. + * - `cloud` (uri null) : fichier cloud-only → seul le delete outbox a un sens. + * - `local-cloud` : les trois modes sont possibles (local, cloud, les deux). + */ +fun deleteModesFor(file: FileEntity): List = when (file.syncStatus) { + FileStatus.LOCAL -> listOf(FileDeleter.DeleteMode.LOCALLY) + FileStatus.CLOUD -> listOf(FileDeleter.DeleteMode.IN_CLOUD) + FileStatus.LOCAL_CLOUD -> listOf( + FileDeleter.DeleteMode.LOCALLY, + FileDeleter.DeleteMode.IN_CLOUD, + FileDeleter.DeleteMode.FULL, + ) + else -> emptyList() } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/res/values-en/strings.xml b/mobile-kotlin/app/src/main/res/values-en/strings.xml index 2d2d4fc..6dafd72 100644 --- a/mobile-kotlin/app/src/main/res/values-en/strings.xml +++ b/mobile-kotlin/app/src/main/res/values-en/strings.xml @@ -48,6 +48,8 @@ No app can open this file. Cloud only This file is not downloaded on this device. Sign in to your server to make it available. + Document kept and synced. + Could not keep this document. Preview truncated — the full file is not displayed. Reading document… Could not read this document. diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 7244014..6e11ac5 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -67,6 +67,8 @@ Aucune application ne peut ouvrir ce fichier. Disponible uniquement sur le cloud Ce fichier n\'est pas téléchargé sur cet appareil. Connecte-toi à ton serveur pour le rendre disponible. + Document gardé et synchronisé. + Impossible de garder ce document. Aperçu tronqué — le fichier complet n\'est pas affiché. Lecture du document… Impossible de lire ce document.