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).
|
* 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")
|
||||||
|
|||||||
+10
-1
@@ -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.
|
||||||
|
|||||||
+6
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-18
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.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
|
||||||
|
|||||||
+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.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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+42
@@ -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. */
|
||||||
|
|||||||
+4
@@ -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,
|
||||||
)
|
)
|
||||||
+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.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>
|
||||||
|
|||||||
Reference in New Issue
Block a user