add actions into document viewer

This commit is contained in:
m
2026-09-14 17:54:49 +02:00
parent 6a991f0e21
commit 2d12cc11aa
4 changed files with 360 additions and 11 deletions
@@ -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<FileDeleter.DeleteMode?>(null) }
var showDeleteConfirm by remember { mutableStateOf(false) }
var fullscreen by rememberSaveable { mutableStateOf(false) }
val canFullscreen = currentFile?.canFocus() == true
@@ -193,11 +229,16 @@ fun DocumentViewerScreen(
modifier = Modifier.fillMaxSize(),
)
} else {
HorizontalPager(
state = pagerState,
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) { page ->
val file = documents.getOrNull(page)
if (file != null) {
@@ -210,8 +251,41 @@ fun DocumentViewerScreen(
)
}
}
// 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(
@@ -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<List<FileEntity>>(emptyList())
val documents: StateFlow<List<FileEntity>> = _documents.asStateFlow()
private val _uiState = MutableStateFlow(ViewerUiState())
val uiState: StateFlow<ViewerUiState> = _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<FileDeleter.DeleteMode> = 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()
}
@@ -48,6 +48,8 @@
<string name="document_open_error">No app can open this file.</string>
<string name="document_cloud_only">Cloud only</string>
<string name="document_cloud_only_hint">This file is not downloaded on this device. Sign in to your server to make it available.</string>
<string name="document_keep_done">Document kept and synced.</string>
<string name="document_keep_error">Could not keep this document.</string>
<string name="document_truncated_notice">Preview truncated — the full file is not displayed.</string>
<string name="document_loading">Reading document…</string>
<string name="document_cannot_read">Could not read this document.</string>
@@ -67,6 +67,8 @@
<string name="document_open_error">Aucune application ne peut ouvrir ce fichier.</string>
<string name="document_cloud_only">Disponible uniquement sur le cloud</string>
<string name="document_cloud_only_hint">Ce fichier n\'est pas téléchargé sur cet appareil. Connecte-toi à ton serveur pour le rendre disponible.</string>
<string name="document_keep_done">Document gardé et synchronisé.</string>
<string name="document_keep_error">Impossible de garder ce document.</string>
<string name="document_truncated_notice">Aperçu tronqué — le fichier complet n\'est pas affiché.</string>
<string name="document_loading">Lecture du document…</string>
<string name="document_cannot_read">Impossible de lire ce document.</string>