create folders and move files into
This commit is contained in:
@@ -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")
|
||||
|
||||
+10
-1
@@ -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.
|
||||
|
||||
+6
@@ -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()
|
||||
}
|
||||
}
|
||||
+1
-18
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
@@ -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
|
||||
+42
@@ -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
|
||||
|
||||
+38
-1
@@ -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) }
|
||||
}
|
||||
}
|
||||
+42
@@ -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. */
|
||||
|
||||
+4
@@ -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,
|
||||
)
|
||||
+39
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user