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). * 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( @Database(
entities = [FolderEntity::class, UserPreferenceEntity::class, FileEntity::class], entities = [FolderEntity::class, UserPreferenceEntity::class, FileEntity::class],
version = 4, version = 5,
exportSchema = false, exportSchema = false,
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -48,6 +48,12 @@ interface FileDao {
@Query("UPDATE files SET \"exists\" = 0, updated_at = :updatedAt WHERE resource_id = :resourceId") @Query("UPDATE files SET \"exists\" = 0, updated_at = :updatedAt WHERE resource_id = :resourceId")
suspend fun markMissing(resourceId: String, updatedAt: Long) 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") @Query("DELETE FROM files WHERE resource_id = :resourceId")
suspend fun remove(resourceId: String) suspend fun remove(resourceId: String)
} }
@@ -17,6 +17,10 @@ interface FolderDao {
@Query("SELECT * FROM folders ORDER BY name ASC") @Query("SELECT * FROM folders ORDER BY name ASC")
suspend fun getAll(): List<FolderEntity> 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") @Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC")
fun observeRootFolders(): Flow<List<FolderEntity>> fun observeRootFolders(): Flow<List<FolderEntity>>
@@ -40,6 +40,8 @@ data class FolderEntity(
val ownerId: String? = null, val ownerId: String? = null,
@ColumnInfo(name = "sync_status") @ColumnInfo(name = "sync_status")
val syncStatus: String = "local", val syncStatus: String = "local",
@ColumnInfo(name = "created_in_app", defaultValue = "0")
val createdInApp: Boolean = false,
@ColumnInfo(name = "added_at") @ColumnInfo(name = "added_at")
val addedAt: Long, val addedAt: Long,
@ColumnInfo(name = "updated_at") @ColumnInfo(name = "updated_at")
@@ -11,6 +11,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
* du compte actif et les préférences utilisateur. * du compte actif et les préférences utilisateur.
* v3 : table `files` — miroir de `FileRow` JS (métadonnées locales + cloud). * v3 : table `files` — miroir de `FileRow` JS (métadonnées locales + cloud).
* v4 : ajout colonne `category` + index sur `files`. * 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 { 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) = suspend fun markMissing(resourceId: String, updatedAt: Long) =
fileDao.markMissing(resourceId, updatedAt) 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 * `GET /files?folderId=...` (1re page, tri serveur) puis upsert cloud de
* chaque fichier. Ne supprime jamais de lignes locales. * chaque fichier. Ne supprime jamais de lignes locales.
@@ -33,6 +33,9 @@ class FolderRepository @Inject constructor(
suspend fun getAll(): List<FolderEntity> = folderDao.getAll() 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? = suspend fun getFolder(resourceId: String): FolderEntity? =
folderDao.getByResourceId(resourceId) folderDao.getByResourceId(resourceId)
@@ -93,6 +96,7 @@ class FolderRepository @Inject constructor(
parentResourceId = parentResourceId ?: existing?.parentResourceId, parentResourceId = parentResourceId ?: existing?.parentResourceId,
ownerId = ownerId ?: existing?.ownerId ?: deviceIdentity.getOrCreate(), ownerId = ownerId ?: existing?.ownerId ?: deviceIdentity.getOrCreate(),
syncStatus = existing?.syncStatus ?: FolderStatus.LOCAL, syncStatus = existing?.syncStatus ?: FolderStatus.LOCAL,
createdInApp = existing?.createdInApp ?: input.createdInApp,
addedAt = existing?.addedAt ?: now, addedAt = existing?.addedAt ?: now,
updatedAt = now, updatedAt = now,
) )
@@ -110,4 +114,6 @@ data class SaveFolderInput(
val name: String, val name: String,
val exists: Boolean? = null, val exists: Boolean? = null,
val resourceId: String? = 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. * ou null si le parent est absente ou si la création échoue.
*/ */
suspend fun createFolder(parentUri: String?, name: String): Uri? { suspend fun createFolder(parentUri: String?, name: String): Uri? {
val parentDocumentUri = toDocumentUri(parentUri) ?: return null val parentDocumentUri = SafUris.toDocumentUri(parentUri) ?: return null
val created = runCatching { val created = runCatching {
DocumentsContract.createDocument( DocumentsContract.createDocument(
resolver, resolver,
@@ -52,21 +52,4 @@ class SafFolderCreator @Inject constructor(
return created 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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CreateNewFolder 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.Folder
import androidx.compose.material.icons.filled.MergeType import androidx.compose.material.icons.filled.MergeType
import androidx.compose.material.icons.filled.Refresh 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.features.connection.ConnectionStatusViewModel
import com.vaultdrop.mobile.ui.components.FileCategoryIcon import com.vaultdrop.mobile.ui.components.FileCategoryIcon
import com.vaultdrop.mobile.ui.components.FolderNameDialog 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.SelectionState
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
import com.vaultdrop.mobile.ui.components.ServerStatusBadge import com.vaultdrop.mobile.ui.components.ServerStatusBadge
@@ -72,6 +74,7 @@ fun FolderDetailScreen(
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
val selection = rememberSelectionState() val selection = rememberSelectionState()
var showCreateDialog by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) }
var showMoveDialog by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(uiState.folderMissing) { 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() } BackHandler(enabled = selection.active) { selection.clear() }
Scaffold( Scaffold(
@@ -117,6 +132,15 @@ fun FolderDetailScreen(
}, },
actions = { actions = {
if (selection.active) { if (selection.active) {
IconButton(
onClick = { showMoveDialog = true },
enabled = selection.ids.isNotEmpty(),
) {
Icon(
Icons.Filled.DriveFileMove,
contentDescription = stringResource(R.string.move_files),
)
}
IconButton( IconButton(
onClick = { onClick = {
val ids = selection.ids.toList() 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 @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.FileRepository
import com.vaultdrop.mobile.data.repository.FolderRepository import com.vaultdrop.mobile.data.repository.FolderRepository
import com.vaultdrop.mobile.data.repository.SaveFolderInput import com.vaultdrop.mobile.data.repository.SaveFolderInput
import com.vaultdrop.mobile.features.saf.FileMover
import com.vaultdrop.mobile.features.saf.SafFolderCreator import com.vaultdrop.mobile.features.saf.SafFolderCreator
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@@ -30,6 +31,10 @@ data class FolderDetailUiState(
val error: String? = null, val error: String? = null,
/** Erreur transitoire de création — affichée en Snackbar puis effacée. */ /** Erreur transitoire de création — affichée en Snackbar puis effacée. */
val createError: String? = null, 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 @HiltViewModel
@@ -39,6 +44,7 @@ class FolderDetailViewModel @Inject constructor(
private val fileRepository: FileRepository, private val fileRepository: FileRepository,
private val tokenProvider: TokenProvider, private val tokenProvider: TokenProvider,
private val safFolderCreator: SafFolderCreator, private val safFolderCreator: SafFolderCreator,
private val fileMover: FileMover,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) : ViewModel() { ) : ViewModel() {
@@ -118,7 +124,7 @@ class FolderDetailViewModel @Inject constructor(
return@launch return@launch
} }
folderRepository.saveFolder( 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, parentResourceId = folder.resourceId,
) )
} }
@@ -128,4 +134,35 @@ class FolderDetailViewModel @Inject constructor(
fun clearCreateError() { fun clearCreateError() {
_uiState.update { it.copy(createError = null) } _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.Add
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CreateNewFolder 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.Folder
import androidx.compose.material.icons.filled.MergeType import androidx.compose.material.icons.filled.MergeType
import androidx.compose.material.icons.filled.Refresh 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.features.sync.SyncViewModel
import com.vaultdrop.mobile.ui.components.FileCategoryIcon import com.vaultdrop.mobile.ui.components.FileCategoryIcon
import com.vaultdrop.mobile.ui.components.FolderNameDialog 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.SelectionState
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
import com.vaultdrop.mobile.ui.components.ServerStatusBadge import com.vaultdrop.mobile.ui.components.ServerStatusBadge
@@ -101,6 +103,7 @@ fun FolderListScreen(
val folderLabel = stringResource(R.string.folder) val folderLabel = stringResource(R.string.folder)
val selection = rememberSelectionState() val selection = rememberSelectionState()
var showCreateDialog by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) }
var showMoveDialog by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val createError = uiState.createError 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. // Racine par défaut : dossier VaultDrop choisi au premier lancement.
val defaultRootLabel = stringResource(R.string.default_root_folder_label) val defaultRootLabel = stringResource(R.string.default_root_folder_label)
var pendingDefaultPick by remember { mutableStateOf<Uri?>(null) } var pendingDefaultPick by remember { mutableStateOf<Uri?>(null) }
@@ -201,6 +216,15 @@ fun FolderListScreen(
}, },
actions = { actions = {
if (selection.active) { if (selection.active) {
IconButton(
onClick = { showMoveDialog = true },
enabled = selection.ids.isNotEmpty(),
) {
Icon(
Icons.Filled.DriveFileMove,
contentDescription = stringResource(R.string.move_files),
)
}
IconButton( IconButton(
onClick = { onClick = {
val ids = selection.ids.toList() 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. */ /** Écran obligatoire du premier lancement : choix/création de la racine VaultDrop. */
@@ -25,4 +25,8 @@ data class FolderListUiState(
val error: String? = null, val error: String? = null,
/** Erreur transitoire de création de dossier — affichée en Snackbar puis effacée. */ /** Erreur transitoire de création de dossier — affichée en Snackbar puis effacée. */
val createError: String? = null, 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.FileRepository
import com.vaultdrop.mobile.data.repository.FolderRepository import com.vaultdrop.mobile.data.repository.FolderRepository
import com.vaultdrop.mobile.data.repository.SaveFolderInput import com.vaultdrop.mobile.data.repository.SaveFolderInput
import com.vaultdrop.mobile.features.saf.FileMover
import com.vaultdrop.mobile.features.saf.SafFolderCreator import com.vaultdrop.mobile.features.saf.SafFolderCreator
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@@ -40,6 +41,7 @@ class FolderListViewModel @Inject constructor(
private val tokenProvider: TokenProvider, private val tokenProvider: TokenProvider,
private val defaultRootStore: DefaultRootStore, private val defaultRootStore: DefaultRootStore,
private val safFolderCreator: SafFolderCreator, private val safFolderCreator: SafFolderCreator,
private val fileMover: FileMover,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) : ViewModel() { ) : ViewModel() {
@@ -95,7 +97,12 @@ class FolderListViewModel @Inject constructor(
} ?: return null } ?: return null
val saved = folderRepository.saveFolder( 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, parentResourceId = rootRoomId,
) )
return saved.resourceId return saved.resourceId
@@ -150,7 +157,7 @@ class FolderListViewModel @Inject constructor(
return@launch return@launch
} }
folderRepository.saveFolder( 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, parentResourceId = root.resourceId,
) )
} }
@@ -161,6 +168,36 @@ class FolderListViewModel @Inject constructor(
_uiState.update { it.copy(createError = null) } _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). */ /** Grille d'accueil : tous les fichiers visibles, groupés par jour (date de référence). */
private fun observeFiles() { private fun observeFiles() {
viewModelScope.launch { viewModelScope.launch {
@@ -21,6 +21,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close 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.MergeType
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -33,10 +34,16 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.domain.FileCategory
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
import com.vaultdrop.mobile.ui.components.FileCategoryIcon 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.SelectionState
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
import com.vaultdrop.mobile.ui.components.ServerStatusBadge import com.vaultdrop.mobile.ui.components.ServerStatusBadge
@@ -74,7 +82,23 @@ fun SearchScreen(
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
val moveFolders by viewModel.moveFolders.collectAsStateWithLifecycle()
val moveError by viewModel.moveError.collectAsStateWithLifecycle()
val selection = rememberSelectionState() 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() } BackHandler(enabled = selection.active) { selection.clear() }
@@ -100,6 +124,15 @@ fun SearchScreen(
}, },
actions = { actions = {
if (selection.active) { if (selection.active) {
IconButton(
onClick = { showMoveDialog = true },
enabled = selection.ids.isNotEmpty(),
) {
Icon(
Icons.Filled.DriveFileMove,
contentDescription = stringResource(R.string.move_files),
)
}
IconButton( IconButton(
onClick = { onClick = {
val ids = selection.ids.toList() val ids = selection.ids.toList()
@@ -125,6 +158,7 @@ fun SearchScreen(
bottomBar = { bottomBar = {
FloatingNavBar(selected = selectedTab, onSelect = onTabSelected) FloatingNavBar(selected = selectedTab, onSelect = onTabSelected)
}, },
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { padding -> ) { padding ->
SearchContent( SearchContent(
uiState = uiState, uiState = uiState,
@@ -135,6 +169,24 @@ fun SearchScreen(
modifier = Modifier.padding(padding), 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 @Composable
@@ -1,30 +1,48 @@
package com.vaultdrop.mobile.ui.search package com.vaultdrop.mobile.ui.search
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.local.entity.FolderEntity
import com.vaultdrop.mobile.data.repository.FileRepository 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SearchViewModel @Inject constructor( class SearchViewModel @Inject constructor(
private val fileRepository: FileRepository, private val fileRepository: FileRepository,
private val folderRepository: FolderRepository,
private val fileMover: FileMover,
@ApplicationContext private val context: Context,
) : ViewModel() { ) : ViewModel() {
private val _searchQuery = MutableStateFlow("") private val _searchQuery = MutableStateFlow("")
private val _selectedCategory = MutableStateFlow<String?>(null) 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) @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val uiState: StateFlow<SearchUiState> = combine( val uiState: StateFlow<SearchUiState> = combine(
_searchQuery.debounce(SEARCH_DEBOUNCE_MS), _searchQuery.debounce(SEARCH_DEBOUNCE_MS),
@@ -53,6 +71,31 @@ class SearchViewModel @Inject constructor(
_selectedCategory.value = category _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?) private data class CategoryQuery(val query: String, val category: String?)
companion object { companion object {
@@ -14,6 +14,11 @@
<string name="new_folder_name_required">The folder name cannot be empty.</string> <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_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="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="folders_section">Folders</string>
<string name="no_folders_yet">No folders yet. Tap "Add a folder" to sync a folder.</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> <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_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_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="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="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_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> <string name="no_files_yet">Aucun fichier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser tes fichiers.</string>