add actions into document viewer
This commit is contained in:
+269
-2
@@ -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,9 +251,42 @@ 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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lecteur focus du document courant : dispatch par catégorie.
|
||||
@@ -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(
|
||||
|
||||
+78
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user