diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt index d6be059..03960c0 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt @@ -15,6 +15,9 @@ interface FileDao { @Query("SELECT * FROM files WHERE folder_resource_id = :folderResourceId AND \"exists\" = 1 ORDER BY name ASC") fun observeByFolder(folderResourceId: String): Flow> + @Query("SELECT * FROM files WHERE folder_resource_id = :folderResourceId AND \"exists\" = 1 ORDER BY name ASC") + suspend fun getByFolderOnce(folderResourceId: String): List + @Query("SELECT * FROM files WHERE \"exists\" = 1 ORDER BY COALESCE(last_modified, added_at) DESC, name ASC") fun observeAllVisible(): Flow> diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt index 64d4ba6..f439105 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt @@ -37,6 +37,10 @@ interface FolderDao { @Query("SELECT * FROM folders WHERE parent_resource_id = :parentResourceId ORDER BY name ASC") suspend fun getByParent(parentResourceId: String): List + /** Snapshot des sous-dossiers visibles (filtre `exists = 1`) — opérations batch hors UI. */ + @Query("SELECT * FROM folders WHERE parent_resource_id = :parentResourceId AND \"exists\" = 1 ORDER BY name ASC") + suspend fun getByParentOnce(parentResourceId: String): List + @Query("SELECT * FROM folders WHERE resource_id = :resourceId LIMIT 1") suspend fun getByResourceId(resourceId: String): FolderEntity? @@ -52,6 +56,10 @@ interface FolderDao { @Query("UPDATE folders SET \"exists\" = 0, updated_at = :updatedAt WHERE resource_id = :resourceId") suspend fun markMissing(resourceId: String, updatedAt: Long) + /** Re-parente des sous-dossiers vers un nouveau parent (fusion). */ + @Query("UPDATE folders SET parent_resource_id = :newParentId, updated_at = :updatedAt WHERE resource_id IN (:resourceIds)") + suspend fun reparent(resourceIds: List, newParentId: String, updatedAt: Long) + /** * Promote le placement après confirmation serveur (`POST /sync/ops` appliqué) : * `local-cloud` si une copie physique existe, sinon `cloud`. Idempotent. diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/PendingOperationDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/PendingOperationDao.kt index 014dd69..e1c4332 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/PendingOperationDao.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/PendingOperationDao.kt @@ -56,6 +56,19 @@ interface PendingOperationDao { """) suspend fun countCreateOperations(resourceId: String): Int + /** + * Variante sans filtre `resource_type` : utilisée par la suppression de + * dossiers (les dossiers sont poussés à la découverte avec `resource_type = + * 'folder'`, pas `'file'`). + */ + @Query(""" + SELECT COUNT(*) FROM pending_operations + WHERE resource_id = :resourceId + AND operation = 'create_resource' + AND status IN ('pending', 'synced') + """) + suspend fun countCreateOperationsAnyType(resourceId: String): Int + @Query("SELECT COUNT(*) FROM pending_operations WHERE status = 'pending'") suspend fun countPending(): Int diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt index c9d692d..9ad9a80 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt @@ -37,6 +37,10 @@ class FileRepository @Inject constructor( fun observeFiles(folderResourceId: String): Flow> = fileDao.observeByFolder(folderResourceId) + /** Snapshot des fichiers visibles d'un dossier (opérations batch hors UI). */ + suspend fun getFilesInFolder(folderResourceId: String): List = + fileDao.getByFolderOnce(folderResourceId) + /** Fichiers visibles de toute l'arborescence — écran d'accueil (grille par date). */ fun observeAllVisible(): Flow> = fileDao.observeAllVisible() diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt index 497cb0b..86d1934 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt @@ -44,6 +44,10 @@ class FolderRepository @Inject constructor( suspend fun getAll(): List = folderDao.getAll() + /** Snapshot des sous-dossiers visibles d'un dossier (opérations batch hors UI). */ + suspend fun getChildren(parentResourceId: String): List = + folderDao.getByParentOnce(parentResourceId) + /** Dossiers créés dans l'app uniquement (cibles du picker de déplacement). */ suspend fun getCreatedInApp(): List = folderDao.getCreatedInApp() @@ -132,6 +136,25 @@ class FolderRepository @Inject constructor( /** Marque un dossier disparu de l'arborescence (`exists = 0`) — jamais supprimé. */ suspend fun markMissing(resourceId: String, updatedAt: Long) = folderDao.markMissing(resourceId, updatedAt) + + /** + * Re-parente des sous-dossiers vers un nouveau parent (fusion de dossiers). + * Transaction atomique : reparent Room + outbox `move_resource` par dossier. + */ + suspend fun reparentSubFolders(resourceIds: List, newParentId: String) { + if (resourceIds.isEmpty()) return + val now = System.currentTimeMillis() + appDatabase.withTransaction { + folderDao.reparent(resourceIds, newParentId, now) + for (resourceId in resourceIds) { + outboxRepository.enqueueMoveResource( + resourceId = resourceId, + resourceType = "folder", + toFolderResourceId = newParentId, + ) + } + } + } } data class SaveFolderInput( diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OutboxRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OutboxRepository.kt index 6791b07..cd1cc4e 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OutboxRepository.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OutboxRepository.kt @@ -149,6 +149,15 @@ class OutboxRepository @Inject constructor( suspend fun hasCreateOperation(resourceId: String): Boolean = pendingOperationDao.countCreateOperations(resourceId) > 0 + /** + * Variante toutes ressources : un `create_resource` a-t-il jamais été + * journalisé pour ce dossier ? Un `delete_resource` n'est émis que si le + * serveur connaît déjà la ressource (les dossiers sont poussés à la + * découverte avec `resource_type = 'folder'`). + */ + suspend fun hasCreateOperationAnyType(resourceId: String): Boolean = + pendingOperationDao.countCreateOperationsAnyType(resourceId) > 0 + /** * Un `move_resource` est-il encore en attente de push ? Tant que l'op est * pendante, le `folder_resource_id` local reflète la cible future : ni le diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt index 9c9e407..9f9d8a7 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt @@ -68,7 +68,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.entity.FolderEntity +import com.vaultdrop.mobile.data.local.entity.FolderStatus import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel import com.vaultdrop.mobile.features.saf.safDisplayName import com.vaultdrop.mobile.features.sync.SyncViewModel @@ -80,6 +82,7 @@ import com.vaultdrop.mobile.ui.components.FileCategoryIcon import com.vaultdrop.mobile.ui.components.FilePendingReviewBadge import com.vaultdrop.mobile.ui.components.FileSyncStatusIcon import com.vaultdrop.mobile.ui.components.FolderNameDialog + import com.vaultdrop.mobile.ui.components.SelectionState import com.vaultdrop.mobile.ui.components.SelectionStatusIcon import com.vaultdrop.mobile.ui.components.ServerStatusBadge @@ -87,6 +90,7 @@ import com.vaultdrop.mobile.ui.components.SyncStatusAction import com.vaultdrop.mobile.ui.components.rememberSelectionState import com.vaultdrop.mobile.ui.components.reviewDelete import com.vaultdrop.mobile.ui.navigation.FloatingNavBar +import com.vaultdrop.mobile.ui.navigation.FolderSelectionNavBar import com.vaultdrop.mobile.ui.navigation.MoveTargetBar import com.vaultdrop.mobile.ui.navigation.NavTab import com.vaultdrop.mobile.ui.navigation.SelectionNavBar @@ -116,6 +120,8 @@ fun FolderListScreen( var showDeleteConfirm by remember { mutableStateOf(false) } var pendingDeleteMode by remember { mutableStateOf(null) } var pendingDeleteReview by remember { mutableStateOf(null) } + var pendingFolderIds by remember { mutableStateOf>(emptyList()) } + var pendingFileIds by remember { mutableStateOf>(emptyList()) } val snackbarHostState = remember { SnackbarHostState() } val createError = uiState.createError @@ -261,29 +267,72 @@ fun FolderListScreen( viewModel.moveSelectionHere(ids) }, ) - selection.active -> SelectionNavBar( - onMove = { viewModel.startMove() }, - onBuildPdf = { - val ids = selection.ids.toList() - selection.clear() - onBuildPdf(ids) - }, - onDeleteModeSelected = { mode -> - val allFiles = uiState.sections.flatMap { section -> - section.rows.flatMap { row -> listOfNotNull(row.left, row.right) } - } - val selectedFiles = allFiles.filter { it.resourceId in selection.ids } - val review = reviewDelete(selectedFiles, mode) - pendingDeleteMode = mode - pendingDeleteReview = review - if (review.hasWarning) { - showDeleteWarning = true - } else { - showDeleteConfirm = true - } - }, - enabled = selection.ids.isNotEmpty(), - ) + selection.active -> if (uiState.view == HomeView.FOLDERS) { + FolderSelectionNavBar( + onDeleteModeSelected = { mode -> + val selectedFolders = uiState.browseSubFolders.filter { + it.resourceId in selection.ids + } + val selectedFiles = uiState.browseFiles.filter { + it.resourceId in selection.ids + } + val incompatible = when (mode) { + FileDeleter.DeleteMode.IN_CLOUD -> + selectedFolders.count { it.syncStatus == FolderStatus.LOCAL } + + selectedFiles.count { it.syncStatus == FileStatus.LOCAL } + FileDeleter.DeleteMode.LOCALLY -> + selectedFolders.count { it.syncStatus == FolderStatus.CLOUD } + + selectedFiles.count { it.syncStatus == FileStatus.CLOUD } + FileDeleter.DeleteMode.FULL -> + selectedFolders.count { + it.syncStatus == FolderStatus.LOCAL || it.syncStatus == FolderStatus.CLOUD + } + selectedFiles.count { + it.syncStatus == FileStatus.LOCAL || it.syncStatus == FileStatus.CLOUD + } + } + pendingFolderIds = selectedFolders.map { it.resourceId } + pendingFileIds = selectedFiles.map { it.resourceId } + pendingDeleteMode = mode + pendingDeleteReview = DeleteReview( + mode = mode, + total = selectedFolders.size + selectedFiles.size, + incompatibleCount = incompatible, + ) + if (incompatible > 0) { + showDeleteWarning = true + } else { + showDeleteConfirm = true + } + }, + deleteEnabled = selection.ids.isNotEmpty(), + ) + } else { + SelectionNavBar( + onMove = { viewModel.startMove() }, + onBuildPdf = { + val ids = selection.ids.toList() + selection.clear() + onBuildPdf(ids) + }, + onDeleteModeSelected = { mode -> + val allFiles = uiState.sections.flatMap { section -> + section.rows.flatMap { row -> listOfNotNull(row.left, row.right) } + } + val selectedFiles = allFiles.filter { it.resourceId in selection.ids } + pendingFolderIds = emptyList() + pendingFileIds = selectedFiles.map { it.resourceId } + val review = reviewDelete(selectedFiles, mode) + pendingDeleteMode = mode + pendingDeleteReview = review + if (review.hasWarning) { + showDeleteWarning = true + } else { + showDeleteConfirm = true + } + }, + enabled = selection.ids.isNotEmpty(), + ) + } else -> FloatingNavBar(selected = selectedTab, onSelect = onTabSelected) } }, @@ -341,26 +390,36 @@ fun FolderListScreen( showDeleteWarning = false pendingDeleteMode = null pendingDeleteReview = null + pendingFolderIds = emptyList() + pendingFileIds = emptyList() }, ) } if (showDeleteConfirm && pendingDeleteMode != null) { DeleteConfirmDialog( - count = selection.ids.size, + count = pendingFolderIds.size + pendingFileIds.size, onConfirm = { - val ids = selection.ids.toList() val mode = pendingDeleteMode!! + val fileIds = selection.ids.toList() selection.clear() showDeleteConfirm = false pendingDeleteMode = null pendingDeleteReview = null - viewModel.deleteSelectedFiles(ids, mode) + if (uiState.view == HomeView.FOLDERS) { + viewModel.deleteFolderSelection(pendingFolderIds, pendingFileIds, mode) + } else { + viewModel.deleteSelectedFiles(fileIds, mode) + } + pendingFolderIds = emptyList() + pendingFileIds = emptyList() }, onDismiss = { showDeleteConfirm = false pendingDeleteMode = null pendingDeleteReview = null + pendingFolderIds = emptyList() + pendingFileIds = emptyList() }, ) } @@ -683,7 +742,11 @@ private fun FolderBrowserContent( } items(subFolders, key = { it.resourceId }) { folder -> - FolderRow(folder, onClick = { onOpenBrowseFolder(folder.resourceId) }) + FolderRow( + folder = folder, + selection = selection, + onClick = { onOpenBrowseFolder(folder.resourceId) }, + ) } // En mode déplacement, le navigateur sert au choix de la cible : les @@ -779,16 +842,29 @@ private fun SectionHeader(label: String) { ) } -/** Carte dossier de l'explorateur — descend d'un niveau au tap. */ +/** Carte dossier de l'explorateur — descend d'un niveau au tap, clic long pour la sélection. */ +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun FolderRow(folder: FolderEntity, onClick: () -> Unit) { +private fun FolderRow( + folder: FolderEntity, + selection: SelectionState, + onClick: () -> Unit, +) { + val selected = folder.resourceId in selection.ids Card( - onClick = onClick, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), modifier = Modifier .fillMaxWidth() - .padding(bottom = 8.dp), + .padding(bottom = 8.dp) + .combinedClickable( + onClick = { + if (selection.active) selection.toggle(folder.resourceId) else onClick() + }, + onLongClick = { + if (!selection.active) selection.start(folder.resourceId) + }, + ), ) { Row( modifier = Modifier @@ -816,6 +892,9 @@ private fun FolderRow(folder: FolderEntity, onClick: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + if (selection.active) { + SelectionStatusIcon(selected = selected) + } } } } diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt index e75a665..e576d61 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt @@ -16,6 +16,7 @@ import com.vaultdrop.mobile.data.repository.FolderRepository import com.vaultdrop.mobile.data.repository.SaveFolderInput import com.vaultdrop.mobile.features.saf.FileMover import com.vaultdrop.mobile.features.saf.FileDeleter +import com.vaultdrop.mobile.features.saf.FolderDeleter import com.vaultdrop.mobile.features.saf.SafFolderCreator import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -45,6 +46,7 @@ class FolderListViewModel @Inject constructor( private val safFolderCreator: SafFolderCreator, private val fileMover: FileMover, private val fileDeleter: FileDeleter, + private val folderDeleter: FolderDeleter, @ApplicationContext private val context: Context, ) : ViewModel() { @@ -311,6 +313,30 @@ class FolderListViewModel @Inject constructor( _uiState.update { it.copy(deleteSuccess = false) } } + /** + * Suppression d'une sélection mixte de l'explorateur Dossiers (dossiers + * et/ou fichiers) selon le mode choisi. Les dossiers sont supprimés avec + * leur contenu récursif, les fichiers individuellement. + */ + fun deleteFolderSelection(folderIds: List, fileIds: List, mode: FileDeleter.DeleteMode) { + viewModelScope.launch { + var failed = 0 + val folders = folderIds.mapNotNull { folderRepository.getFolder(it) } + if (folders.isNotEmpty()) { + failed += folderDeleter.deleteFolders(folders, mode).failed + } + val files = fileIds.mapNotNull { fileRepository.getFile(it) } + if (files.isNotEmpty()) { + failed += fileDeleter.deleteFiles(files, mode).failed + } + if (failed > 0) { + _uiState.update { it.copy(deleteError = context.getString(R.string.delete_error)) } + } else { + _uiState.update { it.copy(deleteSuccess = true) } + } + } + } + /** Grille d'accueil : tous les fichiers visibles, groupés par jour (date de référence). */ private fun observeFiles() { viewModelScope.launch { diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/FloatingNavBar.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/FloatingNavBar.kt index 66cc567..d414e4e 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/FloatingNavBar.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/FloatingNavBar.kt @@ -180,6 +180,40 @@ fun SelectionNavBar( } } +/** Barre d'actions multi-sélection propre aux dossiers : suppression. */ +@Composable +fun FolderSelectionNavBar( + onDeleteModeSelected: (FileDeleter.DeleteMode) -> Unit, + deleteEnabled: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, bottom = 16.dp), + contentAlignment = Alignment.Center, + ) { + Surface( + shape = RoundedCornerShape(30.dp), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 8.dp, + modifier = Modifier.widthIn(max = 400.dp).fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(vertical = 8.dp, horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DeleteDropdownButton( + enabled = deleteEnabled, + onModeSelected = onDeleteModeSelected, + modifier = Modifier.weight(1f), + ) + } + } + } +} + /** Barre du mode déplacement : bouton « Déplacer ici (N) » vers le dossier courant. */ @Composable fun MoveTargetBar( 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 3faf89b..2d2d4fc 100644 --- a/mobile-kotlin/app/src/main/res/values-en/strings.xml +++ b/mobile-kotlin/app/src/main/res/values-en/strings.xml @@ -109,6 +109,15 @@ %1$d selected Assemble PDF Cancel selection + + + Merge + Merge %1$d folder(s) + e.g. important_documents + Source folders — their content will be moved: + Merge + Folder(s) merged + Could not merge the folders Assemble a PDF Nothing here yet. Add files or a note to start assembling. Add a file diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 4ce99fe..7244014 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -155,6 +155,15 @@ %1$d sélectionné(s) Assembler en PDF Annuler la sélection + + + Fusionner + Fusionner %1$d dossier(s) + ex. documents_importants + Dossiers sources — leur contenu sera déplacé : + Fusionner + Dossier(s) fusionné(s) + Impossible de fusionner les dossiers Assembler un PDF Aucun élément. Ajoute des fichiers ou une note pour commencer l\'assemblage. Ajouter un fichier