create folders and move files into

This commit is contained in:
m
2026-09-12 15:34:49 +02:00
parent fca8ef4c13
commit a1f59a3455
20 changed files with 546 additions and 24 deletions
@@ -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() {
@@ -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<String>, 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)
}
@@ -17,6 +17,10 @@ interface FolderDao {
@Query("SELECT * FROM folders ORDER BY name ASC")
suspend fun getAll(): List<FolderEntity>
/** 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<FolderEntity>
@Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC")
fun observeRootFolders(): Flow<List<FolderEntity>>
@@ -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")
@@ -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<Migration> = 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<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
}
@@ -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.
@@ -33,6 +33,9 @@ class FolderRepository @Inject constructor(
suspend fun getAll(): List<FolderEntity> = folderDao.getAll()
/** Dossiers créés dans l'app uniquement (cibles du picker de déplacement). */
suspend fun getCreatedInApp(): List<FolderEntity> = 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,
)
@@ -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<String>, 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()
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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<FolderEntity>,
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<FolderEntity>): List<FolderEntry> {
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<String>()
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
@@ -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
@@ -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<FolderEntity>? = 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<String>, 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) }
}
}
@@ -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<Uri?>(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. */
@@ -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<FolderEntity>? = null,
/** Erreur transitoire de déplacement — affichée en Snackbar puis effacée. */
val moveError: String? = null,
)
@@ -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<String>, 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 {
@@ -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
@@ -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<String?>(null)
private val _moveFolders = MutableStateFlow<List<FolderEntity>?>(null)
val moveFolders: StateFlow<List<FolderEntity>?> = _moveFolders.asStateFlow()
private val _moveError = MutableStateFlow<String?>(null)
val moveError: StateFlow<String?> = _moveError.asStateFlow()
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val uiState: StateFlow<SearchUiState> = 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<String>, 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 {
@@ -14,6 +14,11 @@
<string name="new_folder_name_required">The folder name cannot be empty.</string>
<string name="new_folder_cloud_only">This folder is cloud-only — import a physical folder first to create subfolders inside it.</string>
<string name="new_folder_error">Could not create the folder.</string>
<string name="move_files">Move</string>
<string name="move_files_title">Move to folder</string>
<string name="move_files_count">%1$d file(s)</string>
<string name="move_files_empty">No folders available.</string>
<string name="move_files_error">Could not move the files.</string>
<string name="folders_section">Folders</string>
<string name="no_folders_yet">No folders yet. Tap "Add a folder" to sync a folder.</string>
<string name="no_files_yet">No files yet. Tap "Add a folder" to sync your files.</string>
@@ -15,6 +15,11 @@
<string name="new_folder_name_required">Le nom du dossier ne peut pas être vide.</string>
<string name="new_folder_cloud_only">Ce dossier est disponible uniquement sur le cloud — importe d\'abord un dossier physique pour y créer des sous-dossiers.</string>
<string name="new_folder_error">Impossible de créer le dossier.</string>
<string name="move_files">Déplacer</string>
<string name="move_files_title">Déplacer vers un dossier</string>
<string name="move_files_count">%1$d fichier(s)</string>
<string name="move_files_empty">Aucun dossier disponible.</string>
<string name="move_files_error">Impossible de déplacer les fichiers.</string>
<string name="folders_section">Dossiers</string>
<string name="no_folders_yet">Aucun dossier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser un dossier.</string>
<string name="no_files_yet">Aucun fichier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser tes fichiers.</string>