diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt index edc9ca0..70e8a91 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt @@ -11,11 +11,12 @@ import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity /* * DB SQLite locale, `dot.db` (même nom que la version Expo). - * v1: folders ; v2: user_preferences ; v3: files ; v4: category sur files. + * v1: folders ; v2: user_preferences ; v3: files ; v4: category sur files ; + * v5: created_in_app sur folders. */ @Database( entities = [FolderEntity::class, UserPreferenceEntity::class, FileEntity::class], - version = 4, + version = 5, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { 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 f608c6c..d91a538 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 @@ -48,6 +48,12 @@ interface FileDao { @Query("UPDATE files SET \"exists\" = 0, updated_at = :updatedAt WHERE resource_id = :resourceId") suspend fun markMissing(resourceId: String, updatedAt: Long) + @Query("UPDATE files SET folder_resource_id = :folderId, updated_at = :updatedAt WHERE resource_id IN (:resourceIds)") + suspend fun moveToFolder(resourceIds: List, folderId: String, updatedAt: Long) + + @Query("UPDATE files SET uri = :uri WHERE resource_id = :resourceId") + suspend fun updateUri(resourceId: String, uri: String?) + @Query("DELETE FROM files WHERE resource_id = :resourceId") suspend fun remove(resourceId: String) } \ No newline at end of file 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 fdb4a4e..163592c 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 @@ -17,6 +17,10 @@ interface FolderDao { @Query("SELECT * FROM folders ORDER BY name ASC") suspend fun getAll(): List + /** Dossiers créés dans l'app (`created_in_app = 1`) — cibles du picker de déplacement. */ + @Query("SELECT * FROM folders WHERE \"created_in_app\" = 1 ORDER BY name ASC") + suspend fun getCreatedInApp(): List + @Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC") fun observeRootFolders(): Flow> diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FolderEntity.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FolderEntity.kt index d6a840d..f8572c0 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FolderEntity.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FolderEntity.kt @@ -40,6 +40,8 @@ data class FolderEntity( val ownerId: String? = null, @ColumnInfo(name = "sync_status") val syncStatus: String = "local", + @ColumnInfo(name = "created_in_app", defaultValue = "0") + val createdInApp: Boolean = false, @ColumnInfo(name = "added_at") val addedAt: Long, @ColumnInfo(name = "updated_at") diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt index 2ac47f3..2860ee7 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt @@ -11,6 +11,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase * du compte actif et les préférences utilisateur. * v3 : table `files` — miroir de `FileRow` JS (métadonnées locales + cloud). * v4 : ajout colonne `category` + index sur `files`. + * v5 : ajout colonne `created_in_app` sur `folders` (marqueur « créé dans + * l'app » — 0 par défaut pour les dossiers importés/Parcourus SAF, 1 pour la + * feature « Créer un dossier ». Utilisé pour filtrer le picker de déplacement). */ object Migrations { @@ -66,5 +69,11 @@ object Migrations { } } - val ALL: Array = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) + private val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `folders` ADD COLUMN `created_in_app` INTEGER NOT NULL DEFAULT 0") + } + } + + val ALL: Array = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) } \ No newline at end of file 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 c11397b..45fb5ff 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 @@ -88,6 +88,13 @@ class FileRepository @Inject constructor( suspend fun markMissing(resourceId: String, updatedAt: Long) = fileDao.markMissing(resourceId, updatedAt) + /** Met à jour la cible dossier d'un fichier (déplacement local / cloud-only). */ + suspend fun applyMove(resourceId: String, folderId: String, newUri: String?) { + val now = System.currentTimeMillis() + fileDao.moveToFolder(listOf(resourceId), folderId, now) + if (newUri != null) fileDao.updateUri(resourceId, newUri) + } + /** * `GET /files?folderId=...` (1re page, tri serveur) puis upsert cloud de * chaque fichier. Ne supprime jamais de lignes locales. 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 d9739aa..d84abd0 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 @@ -33,6 +33,9 @@ class FolderRepository @Inject constructor( suspend fun getAll(): List = folderDao.getAll() + /** Dossiers créés dans l'app uniquement (cibles du picker de déplacement). */ + suspend fun getCreatedInApp(): List = folderDao.getCreatedInApp() + suspend fun getFolder(resourceId: String): FolderEntity? = folderDao.getByResourceId(resourceId) @@ -93,6 +96,7 @@ class FolderRepository @Inject constructor( parentResourceId = parentResourceId ?: existing?.parentResourceId, ownerId = ownerId ?: existing?.ownerId ?: deviceIdentity.getOrCreate(), syncStatus = existing?.syncStatus ?: FolderStatus.LOCAL, + createdInApp = existing?.createdInApp ?: input.createdInApp, addedAt = existing?.addedAt ?: now, updatedAt = now, ) @@ -110,4 +114,6 @@ data class SaveFolderInput( val name: String, val exists: Boolean? = null, val resourceId: String? = null, + /** `true` si le dossier est créé via la feature « Créer un dossier » (picker de déplacement). */ + val createdInApp: Boolean = false, ) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FileMover.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FileMover.kt new file mode 100644 index 0000000..44aeb6c --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FileMover.kt @@ -0,0 +1,77 @@ +package com.vaultdrop.mobile.features.saf + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import com.vaultdrop.mobile.data.repository.FileRepository +import com.vaultdrop.mobile.data.repository.FolderRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Déplacement de fichiers (multi-select) entre dossiers. + * + * - fichier physique (uri ≠ null) + dossier cible physique → relocation SAF + * réelle via `DocumentsContract.moveDocument` (l'arborescence locale reste + * cohérente avec Room) ; en cas d'échec (permission/edge provider), repli + * bas de gamme : métadonnée seule (`folderResourceId`), la marche suivante + * re-réconciliera. + * - fichier cloud-only → mise à jour de la métadonnée de dossier uniquement + * (pas de poussée serveur en V1 — pas d'outbox). + */ +@Singleton +class FileMover @Inject constructor( + @ApplicationContext private val context: Context, + private val fileRepository: FileRepository, + private val folderRepository: FolderRepository, +) { + + private val resolver: ContentResolver get() = context.contentResolver + + suspend fun moveFiles(resourceIds: List, targetFolderId: String) { + val target = folderRepository.getFolder(targetFolderId) + ?: throw IllegalArgumentException("unknown target folder: $targetFolderId") + val targetDoc = SafUris.toDocumentUri(target.uri) + + // I/O ContentResolver (DocumentsContract) hors du thread main. + withContext(Dispatchers.IO) { + for (resourceId in resourceIds) { + val file = fileRepository.getFile(resourceId) ?: continue + val physicalTarget = file.uri != null && targetDoc != null + + val newUri = if (physicalTarget) { + tryMove(file.uri, file.folderResourceId, targetDoc) + } else { + null + } + fileRepository.applyMove(resourceId, targetFolderId, newUri ?: file.uri) + } + } + } + + /** + * Tente la relocation SAF. Retourne le nouvel uri du document (peut être + * identique à l'ancien), ou null en échec → repli métadonnée seule. + */ + private suspend fun tryMove(fileUri: String?, sourceFolderId: String, targetDoc: Uri): String? { + if (fileUri == null) return null + val sourceFolder = folderRepository.getFolder(sourceFolderId) + val sourceDoc = SafUris.toDocumentUri(sourceFolder?.uri) + if (sourceDoc == null) return null + return runCatching { + DocumentsContract.moveDocument( + resolver, + Uri.parse(fileUri), + sourceDoc, + targetDoc, + ) + }.onSuccess { moved -> Timber.d("moved %s -> %s (%s)", fileUri, targetDoc, moved) } + .onFailure { Timber.w(it, "physical move failed for %s (metadata-only)", fileUri) } + .getOrNull()?.toString() + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafFolderCreator.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafFolderCreator.kt index 794c058..fd5621e 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafFolderCreator.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafFolderCreator.kt @@ -29,7 +29,7 @@ class SafFolderCreator @Inject constructor( * ou null si le parent est absente ou si la création échoue. */ suspend fun createFolder(parentUri: String?, name: String): Uri? { - val parentDocumentUri = toDocumentUri(parentUri) ?: return null + val parentDocumentUri = SafUris.toDocumentUri(parentUri) ?: return null val created = runCatching { DocumentsContract.createDocument( resolver, @@ -52,21 +52,4 @@ class SafFolderCreator @Inject constructor( return created } - - /** - * `DocumentsContract.createDocument` attend un URI *document*, pas un URI - * *tree*. Convertit un tree URI en document URI (équivalent au dossier - * racine de l'arbre) — les deux autorités sont identiques. - */ - private fun toDocumentUri(uri: String?): Uri? { - val raw = uri?.let(Uri::parse) ?: return null - return if (DocumentsContract.isTreeUri(raw)) { - DocumentsContract.buildDocumentUriUsingTree( - raw, - DocumentsContract.getTreeDocumentId(raw), - ) - } else { - raw - } - } } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafUris.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafUris.kt new file mode 100644 index 0000000..fdd2723 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafUris.kt @@ -0,0 +1,25 @@ +package com.vaultdrop.mobile.features.saf + +import android.net.Uri +import android.provider.DocumentsContract + +/** + * Conversions d'URI SAF partagées par SafFolderCreator et FileMover. + * `DocumentsContract` attend des URI *document* ; les racines importées sont + * stockées en URI *tree* et doivent être converties avant createDocument/ + * moveDocument. + */ +object SafUris { + + fun toDocumentUri(uri: String?): Uri? { + val raw = uri?.let(Uri::parse) ?: return null + return if (DocumentsContract.isTreeUri(raw)) { + DocumentsContract.buildDocumentUriUsingTree( + raw, + DocumentsContract.getTreeDocumentId(raw), + ) + } else { + raw + } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/MoveFolderPickerDialog.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/MoveFolderPickerDialog.kt new file mode 100644 index 0000000..153a7d3 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/MoveFolderPickerDialog.kt @@ -0,0 +1,135 @@ +package com.vaultdrop.mobile.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 com.vaultdrop.mobile.R +import com.vaultdrop.mobile.data.local.entity.FolderEntity + +/** + * Picker de destination pour déplacer N fichiers : liste arborescente (indent + * selon la profondeur réelle calculée depuis `parentResourceId`), tap = choix. + */ +@Composable +fun MoveFolderPickerDialog( + folders: List, + fileCount: Int, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + val entries = remember(folders) { buildFolderEntries(folders) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.move_files_count, fileCount)) }, + text = { + if (entries.isEmpty()) { + Text( + text = stringResource(R.string.move_files_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn(modifier = Modifier.height(360.dp)) { + items(entries, key = { it.folder.resourceId }) { entry -> + FolderPickerRow( + folder = entry.folder, + depth = entry.depth, + onClick = { onConfirm(entry.folder.resourceId) }, + ) + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.pdf_builder_cancel)) + } + }, + ) +} + +private data class FolderEntry(val folder: FolderEntity, val depth: Int) + +/** Profondeur réelle d'un dossier (racines = 0), calculée par chaînage parent. */ +private fun buildFolderEntries(folders: List): List { + if (folders.isEmpty()) return emptyList() + val byId = folders.associateBy { it.resourceId } + + fun depth(folder: FolderEntity): Int { + var current = folder + var d = 0 + val visited = HashSet() + while (current.parentResourceId != null && d < MAX_DEPTH) { + if (!visited.add(current.parentResourceId)) break + current = byId[current.parentResourceId] ?: break + d++ + } + return d + } + + return folders + .sortedWith(compareBy({ depth(it) }, { it.name.lowercase() })) + .map { FolderEntry(it, depth(it)) } +} + +@Composable +private fun FolderPickerRow( + folder: FolderEntity, + depth: Int, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(start = 8.dp * depth + 8.dp, top = 10.dp, bottom = 10.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Filled.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = folder.name, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Medium, + ) + Text( + text = stringResource(R.string.folder), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private const val MAX_DEPTH = 16 \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt index 55b1884..a954410 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material.icons.filled.DriveFileMove import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.MergeType import androidx.compose.material.icons.filled.Refresh @@ -51,6 +52,7 @@ import com.vaultdrop.mobile.data.local.entity.FolderEntity import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel import com.vaultdrop.mobile.ui.components.FileCategoryIcon import com.vaultdrop.mobile.ui.components.FolderNameDialog +import com.vaultdrop.mobile.ui.components.MoveFolderPickerDialog import com.vaultdrop.mobile.ui.components.SelectionState import com.vaultdrop.mobile.ui.components.SelectionStatusIcon import com.vaultdrop.mobile.ui.components.ServerStatusBadge @@ -72,6 +74,7 @@ fun FolderDetailScreen( val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() val selection = rememberSelectionState() var showCreateDialog by remember { mutableStateOf(false) } + var showMoveDialog by remember { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(uiState.folderMissing) { @@ -86,6 +89,18 @@ fun FolderDetailScreen( } } + LaunchedEffect(uiState.moveError) { + val moveError = uiState.moveError + if (moveError != null) { + snackbarHostState.showSnackbar(moveError) + viewModel.clearMoveError() + } + } + + LaunchedEffect(showMoveDialog) { + if (showMoveDialog) viewModel.loadMoveFolders() + } + BackHandler(enabled = selection.active) { selection.clear() } Scaffold( @@ -117,6 +132,15 @@ fun FolderDetailScreen( }, actions = { if (selection.active) { + IconButton( + onClick = { showMoveDialog = true }, + enabled = selection.ids.isNotEmpty(), + ) { + Icon( + Icons.Filled.DriveFileMove, + contentDescription = stringResource(R.string.move_files), + ) + } IconButton( onClick = { val ids = selection.ids.toList() @@ -171,6 +195,24 @@ fun FolderDetailScreen( }, ) } + + val moveFolders = uiState.moveFolders + if (showMoveDialog && moveFolders != null) { + MoveFolderPickerDialog( + folders = moveFolders, + fileCount = selection.ids.size, + onDismiss = { + showMoveDialog = false + viewModel.closeMovePicker() + }, + onConfirm = { folderId -> + val ids = selection.ids.toList() + selection.clear() + showMoveDialog = false + viewModel.moveSelectedFiles(ids, folderId) + }, + ) + } } @Composable diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailViewModel.kt index 6ac1fe6..f34813b 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailViewModel.kt @@ -11,6 +11,7 @@ import com.vaultdrop.mobile.data.local.entity.FolderEntity import com.vaultdrop.mobile.data.repository.FileRepository 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.SafFolderCreator import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -30,6 +31,10 @@ data class FolderDetailUiState( val error: String? = null, /** Erreur transitoire de création — affichée en Snackbar puis effacée. */ val createError: String? = null, + /** Dossiers disponibles pour le picker de déplacement (null = pas chargé). */ + val moveFolders: List? = null, + /** Erreur transitoire de déplacement — affichée en Snackbar puis effacée. */ + val moveError: String? = null, ) @HiltViewModel @@ -39,6 +44,7 @@ class FolderDetailViewModel @Inject constructor( private val fileRepository: FileRepository, private val tokenProvider: TokenProvider, private val safFolderCreator: SafFolderCreator, + private val fileMover: FileMover, @ApplicationContext private val context: Context, ) : ViewModel() { @@ -118,7 +124,7 @@ class FolderDetailViewModel @Inject constructor( return@launch } folderRepository.saveFolder( - input = SaveFolderInput(uri = created.toString(), name = trimmed, exists = true), + input = SaveFolderInput(uri = created.toString(), name = trimmed, exists = true, createdInApp = true), parentResourceId = folder.resourceId, ) } @@ -128,4 +134,35 @@ class FolderDetailViewModel @Inject constructor( fun clearCreateError() { _uiState.update { it.copy(createError = null) } } + + /** Charge les dossiers éligibles à recevoir les fichiers sélectionnés. */ + fun loadMoveFolders() { + viewModelScope.launch { + val all = folderRepository.getCreatedInApp() + val exclude = folderResourceId + _uiState.update { it.copy(moveFolders = all.filter { f -> f.resourceId != exclude }) } + } + } + + /** Déplace les fichiers vers le dossier cible puis ferme le picker. */ + fun moveSelectedFiles(resourceIds: List, targetFolderId: String) { + viewModelScope.launch { + runCatching { fileMover.moveFiles(resourceIds, targetFolderId) } + .onFailure { + _uiState.update { state -> + state.copy(moveError = context.getString(R.string.move_files_error)) + } + } + _uiState.update { it.copy(moveFolders = null) } + } + } + + /** Ferme le picker sans déplacer (annulation). */ + fun closeMovePicker() { + _uiState.update { it.copy(moveFolders = null) } + } + + fun clearMoveError() { + _uiState.update { it.copy(moveError = null) } + } } \ No newline at end of file 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 653b562..a4a073d 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 @@ -30,6 +30,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material.icons.filled.DriveFileMove import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.MergeType import androidx.compose.material.icons.filled.Refresh @@ -71,6 +72,7 @@ import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel import com.vaultdrop.mobile.features.sync.SyncViewModel import com.vaultdrop.mobile.ui.components.FileCategoryIcon import com.vaultdrop.mobile.ui.components.FolderNameDialog +import com.vaultdrop.mobile.ui.components.MoveFolderPickerDialog import com.vaultdrop.mobile.ui.components.SelectionState import com.vaultdrop.mobile.ui.components.SelectionStatusIcon import com.vaultdrop.mobile.ui.components.ServerStatusBadge @@ -101,6 +103,7 @@ fun FolderListScreen( val folderLabel = stringResource(R.string.folder) val selection = rememberSelectionState() var showCreateDialog by remember { mutableStateOf(false) } + var showMoveDialog by remember { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } val createError = uiState.createError @@ -111,6 +114,18 @@ fun FolderListScreen( } } + LaunchedEffect(uiState.moveError) { + val moveError = uiState.moveError + if (moveError != null) { + snackbarHostState.showSnackbar(moveError) + viewModel.clearMoveError() + } + } + + LaunchedEffect(showMoveDialog) { + if (showMoveDialog) viewModel.loadMoveFolders() + } + // Racine par défaut : dossier VaultDrop choisi au premier lancement. val defaultRootLabel = stringResource(R.string.default_root_folder_label) var pendingDefaultPick by remember { mutableStateOf(null) } @@ -201,6 +216,15 @@ fun FolderListScreen( }, actions = { if (selection.active) { + IconButton( + onClick = { showMoveDialog = true }, + enabled = selection.ids.isNotEmpty(), + ) { + Icon( + Icons.Filled.DriveFileMove, + contentDescription = stringResource(R.string.move_files), + ) + } IconButton( onClick = { val ids = selection.ids.toList() @@ -253,6 +277,24 @@ fun FolderListScreen( }, ) } + + val moveFolders = uiState.moveFolders + if (showMoveDialog && moveFolders != null) { + MoveFolderPickerDialog( + folders = moveFolders, + fileCount = selection.ids.size, + onDismiss = { + showMoveDialog = false + viewModel.closeMovePicker() + }, + onConfirm = { folderId -> + val ids = selection.ids.toList() + selection.clear() + showMoveDialog = false + viewModel.moveSelectedFiles(ids, folderId) + }, + ) + } } /** Écran obligatoire du premier lancement : choix/création de la racine VaultDrop. */ diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt index 67f9a40..21c5319 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt @@ -25,4 +25,8 @@ data class FolderListUiState( val error: String? = null, /** Erreur transitoire de création de dossier — affichée en Snackbar puis effacée. */ val createError: String? = null, + /** Dossiers disponibles pour le picker de déplacement (null = pas chargé). */ + val moveFolders: List? = null, + /** Erreur transitoire de déplacement — affichée en Snackbar puis effacée. */ + val moveError: String? = null, ) \ No newline at end of file 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 c29d03a..2b415cb 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 @@ -15,6 +15,7 @@ import com.vaultdrop.mobile.data.remote.ApiException import com.vaultdrop.mobile.data.repository.FileRepository 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.SafFolderCreator import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -40,6 +41,7 @@ class FolderListViewModel @Inject constructor( private val tokenProvider: TokenProvider, private val defaultRootStore: DefaultRootStore, private val safFolderCreator: SafFolderCreator, + private val fileMover: FileMover, @ApplicationContext private val context: Context, ) : ViewModel() { @@ -95,7 +97,12 @@ class FolderListViewModel @Inject constructor( } ?: return null val saved = folderRepository.saveFolder( - input = SaveFolderInput(uri = vaultFolderUri.toString(), name = label, exists = true), + input = SaveFolderInput( + uri = vaultFolderUri.toString(), + name = label, + exists = true, + createdInApp = true, + ), parentResourceId = rootRoomId, ) return saved.resourceId @@ -150,7 +157,7 @@ class FolderListViewModel @Inject constructor( return@launch } folderRepository.saveFolder( - input = SaveFolderInput(uri = created.toString(), name = trimmed, exists = true), + input = SaveFolderInput(uri = created.toString(), name = trimmed, exists = true, createdInApp = true), parentResourceId = root.resourceId, ) } @@ -161,6 +168,36 @@ class FolderListViewModel @Inject constructor( _uiState.update { it.copy(createError = null) } } + /** Charge les dossiers éligibles pour le picker de déplacement. */ + fun loadMoveFolders() { + viewModelScope.launch { + val moveFolders = folderRepository.getCreatedInApp() + _uiState.update { it.copy(moveFolders = moveFolders) } + } + } + + /** Déplace les fichiers vers le dossier cible puis ferme le picker. */ + fun moveSelectedFiles(resourceIds: List, targetFolderId: String) { + viewModelScope.launch { + runCatching { fileMover.moveFiles(resourceIds, targetFolderId) } + .onFailure { + _uiState.update { state -> + state.copy(moveError = context.getString(R.string.move_files_error)) + } + } + _uiState.update { it.copy(moveFolders = null) } + } + } + + /** Ferme le picker sans déplacer (annulation). */ + fun closeMovePicker() { + _uiState.update { it.copy(moveFolders = null) } + } + + fun clearMoveError() { + _uiState.update { it.copy(moveError = null) } + } + /** 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/search/SearchScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchScreen.kt index 4c9bdd8..fd5a5b5 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DriveFileMove import androidx.compose.material.icons.filled.MergeType import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Card @@ -33,10 +34,16 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -52,6 +59,7 @@ 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.ui.components.FileCategoryIcon +import com.vaultdrop.mobile.ui.components.MoveFolderPickerDialog import com.vaultdrop.mobile.ui.components.SelectionState import com.vaultdrop.mobile.ui.components.SelectionStatusIcon import com.vaultdrop.mobile.ui.components.ServerStatusBadge @@ -74,7 +82,23 @@ fun SearchScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() + val moveFolders by viewModel.moveFolders.collectAsStateWithLifecycle() + val moveError by viewModel.moveError.collectAsStateWithLifecycle() val selection = rememberSelectionState() + var showMoveDialog by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(moveError) { + val message = moveError + if (message != null) { + snackbarHostState.showSnackbar(message) + viewModel.clearMoveError() + } + } + + LaunchedEffect(showMoveDialog) { + if (showMoveDialog) viewModel.loadMoveFolders() + } BackHandler(enabled = selection.active) { selection.clear() } @@ -100,6 +124,15 @@ fun SearchScreen( }, actions = { if (selection.active) { + IconButton( + onClick = { showMoveDialog = true }, + enabled = selection.ids.isNotEmpty(), + ) { + Icon( + Icons.Filled.DriveFileMove, + contentDescription = stringResource(R.string.move_files), + ) + } IconButton( onClick = { val ids = selection.ids.toList() @@ -125,6 +158,7 @@ fun SearchScreen( bottomBar = { FloatingNavBar(selected = selectedTab, onSelect = onTabSelected) }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { padding -> SearchContent( uiState = uiState, @@ -135,6 +169,24 @@ fun SearchScreen( modifier = Modifier.padding(padding), ) } + + val folders = moveFolders + if (showMoveDialog && folders != null) { + MoveFolderPickerDialog( + folders = folders, + fileCount = selection.ids.size, + onDismiss = { + showMoveDialog = false + viewModel.closeMovePicker() + }, + onConfirm = { folderId -> + val ids = selection.ids.toList() + selection.clear() + showMoveDialog = false + viewModel.moveSelectedFiles(ids, folderId) + }, + ) + } } @Composable diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchViewModel.kt index 9840a44..cc9fbae 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchViewModel.kt @@ -1,30 +1,48 @@ package com.vaultdrop.mobile.ui.search +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.FolderEntity import com.vaultdrop.mobile.data.repository.FileRepository +import com.vaultdrop.mobile.data.repository.FolderRepository +import com.vaultdrop.mobile.features.saf.FileMover import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SearchViewModel @Inject constructor( private val fileRepository: FileRepository, + private val folderRepository: FolderRepository, + private val fileMover: FileMover, + @ApplicationContext private val context: Context, ) : ViewModel() { private val _searchQuery = MutableStateFlow("") private val _selectedCategory = MutableStateFlow(null) + private val _moveFolders = MutableStateFlow?>(null) + val moveFolders: StateFlow?> = _moveFolders.asStateFlow() + + private val _moveError = MutableStateFlow(null) + val moveError: StateFlow = _moveError.asStateFlow() + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) val uiState: StateFlow = combine( _searchQuery.debounce(SEARCH_DEBOUNCE_MS), @@ -53,6 +71,31 @@ class SearchViewModel @Inject constructor( _selectedCategory.value = category } + fun loadMoveFolders() { + viewModelScope.launch { + _moveFolders.value = folderRepository.getCreatedInApp() + } + } + + /** Déplace les fichiers vers le dossier cible puis ferme le picker. */ + fun moveSelectedFiles(resourceIds: List, targetFolderId: String) { + viewModelScope.launch { + runCatching { fileMover.moveFiles(resourceIds, targetFolderId) } + .onFailure { + _moveError.update { context.getString(R.string.move_files_error) } + } + _moveFolders.value = null + } + } + + fun closeMovePicker() { + _moveFolders.value = null + } + + fun clearMoveError() { + _moveError.value = null + } + private data class CategoryQuery(val query: String, val category: String?) companion object { 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 e9ae92a..933b925 100644 --- a/mobile-kotlin/app/src/main/res/values-en/strings.xml +++ b/mobile-kotlin/app/src/main/res/values-en/strings.xml @@ -14,6 +14,11 @@ The folder name cannot be empty. This folder is cloud-only — import a physical folder first to create subfolders inside it. Could not create the folder. + Move + Move to folder + %1$d file(s) + No folders available. + Could not move the files. Folders No folders yet. Tap "Add a folder" to sync a folder. No files yet. Tap "Add a folder" to sync your files. diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index c956cd2..0f1f827 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -15,6 +15,11 @@ Le nom du dossier ne peut pas être vide. Ce dossier est disponible uniquement sur le cloud — importe d\'abord un dossier physique pour y créer des sous-dossiers. Impossible de créer le dossier. + Déplacer + Déplacer vers un dossier + %1$d fichier(s) + Aucun dossier disponible. + Impossible de déplacer les fichiers. Dossiers Aucun dossier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser un dossier. Aucun fichier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser tes fichiers.