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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -44,7 +53,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat 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.data.local.entity.FileEntity
import com.vaultdrop.mobile.domain.FileCategory import com.vaultdrop.mobile.domain.FileCategory
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel 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.features.sync.SyncViewModel
import com.vaultdrop.mobile.ui.components.DeleteConfirmDialog
import com.vaultdrop.mobile.ui.components.ServerStatusBadge import com.vaultdrop.mobile.ui.components.ServerStatusBadge
import com.vaultdrop.mobile.ui.components.SyncStatusAction import com.vaultdrop.mobile.ui.components.SyncStatusAction
import com.vaultdrop.mobile.ui.components.categoryValue import com.vaultdrop.mobile.ui.components.categoryValue
@@ -96,6 +110,7 @@ fun DocumentViewerScreen(
viewModel: DocumentViewerViewModel = hiltViewModel(), viewModel: DocumentViewerViewModel = hiltViewModel(),
) { ) {
val documents by viewModel.documents.collectAsStateWithLifecycle() val documents by viewModel.documents.collectAsStateWithLifecycle()
val viewerState by viewModel.uiState.collectAsStateWithLifecycle()
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
val pagerState = rememberPagerState(pageCount = { documents.size }) val pagerState = rememberPagerState(pageCount = { documents.size })
@@ -108,6 +123,9 @@ fun DocumentViewerScreen(
pagerState.scrollToPage(index) pagerState.scrollToPage(index)
jumpPending = false 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) } var fullscreen by rememberSaveable { mutableStateOf(false) }
val canFullscreen = currentFile?.canFocus() == true val canFullscreen = currentFile?.canFocus() == true
@@ -193,25 +229,63 @@ fun DocumentViewerScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} else { } else {
HorizontalPager( Column(
state = pagerState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(padding),
) { page -> ) {
val file = documents.getOrNull(page) HorizontalPager(
if (file != null) { state = pagerState,
DocumentViewerPage( modifier = Modifier
file = file, .fillMaxWidth()
onOpenExternalFailed = onOpenExternalFailed, .weight(1f),
onEnterFullscreen = file.canFocus() ) { page ->
.takeIf { it } val file = documents.getOrNull(page)
?.let { { fullscreen = true } }, 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 @Composable
private fun MetadataRow(label: String, value: String) { private fun MetadataRow(label: String, value: String) {
Row( Row(
@@ -1,29 +1,53 @@
package com.vaultdrop.mobile.ui.document package com.vaultdrop.mobile.ui.document
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.FileEntity 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.local.orderedByReferenceDateDesc
import com.vaultdrop.mobile.data.repository.FileRepository import com.vaultdrop.mobile.data.repository.FileRepository
import com.vaultdrop.mobile.features.saf.FileDeleter
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
/** /**
* Consultation de documents : expose la liste complète des fichiers (même tri * 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. * 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 @HiltViewModel
class DocumentViewerViewModel @Inject constructor( class DocumentViewerViewModel @Inject constructor(
private val fileRepository: FileRepository, private val fileRepository: FileRepository,
private val fileDeleter: FileDeleter,
@ApplicationContext private val context: Context,
) : ViewModel() { ) : 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()) private val _documents = MutableStateFlow<List<FileEntity>>(emptyList())
val documents: StateFlow<List<FileEntity>> = _documents.asStateFlow() val documents: StateFlow<List<FileEntity>> = _documents.asStateFlow()
private val _uiState = MutableStateFlow(ViewerUiState())
val uiState: StateFlow<ViewerUiState> = _uiState.asStateFlow()
init { init {
viewModelScope.launch { viewModelScope.launch {
fileRepository.observeAllVisible().collect { files -> 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_open_error">No app can open this file.</string>
<string name="document_cloud_only">Cloud only</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_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_truncated_notice">Preview truncated — the full file is not displayed.</string>
<string name="document_loading">Reading document…</string> <string name="document_loading">Reading document…</string>
<string name="document_cannot_read">Could not read this 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_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">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_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_truncated_notice">Aperçu tronqué — le fichier complet n\'est pas affiché.</string>
<string name="document_loading">Lecture du document…</string> <string name="document_loading">Lecture du document…</string>
<string name="document_cannot_read">Impossible de lire ce document.</string> <string name="document_cannot_read">Impossible de lire ce document.</string>