add fichiers filters

This commit is contained in:
m
2026-09-12 18:49:43 +02:00
parent 4e7d368b2d
commit ea59c49547
7 changed files with 631 additions and 211 deletions
@@ -23,13 +23,17 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
@@ -44,6 +48,7 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@@ -55,12 +60,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R
@@ -70,12 +77,12 @@ 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
import com.vaultdrop.mobile.ui.components.rememberSelectionState
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
import com.vaultdrop.mobile.ui.navigation.MoveTargetBar
import com.vaultdrop.mobile.ui.navigation.NavTab
import com.vaultdrop.mobile.ui.navigation.SelectionNavBar
import kotlinx.coroutines.delay
@@ -88,7 +95,6 @@ fun FolderListScreen(
selectedTab: NavTab,
onTabSelected: (NavTab) -> Unit,
onOpenDocument: (String) -> Unit,
onOpenFolder: (String) -> Unit,
onBuildPdf: (List<String>) -> Unit,
syncViewModel: SyncViewModel,
connectionStatusViewModel: ConnectionStatusViewModel,
@@ -102,7 +108,6 @@ 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
@@ -121,8 +126,11 @@ fun FolderListScreen(
}
}
LaunchedEffect(showMoveDialog) {
if (showMoveDialog) viewModel.loadMoveFolders()
LaunchedEffect(uiState.moveSuccess) {
if (uiState.moveSuccess) {
snackbarHostState.showSnackbar(context.getString(R.string.move_success))
viewModel.clearMoveSuccess()
}
}
// Racine par défaut : dossier VaultDrop choisi au premier lancement.
@@ -191,21 +199,31 @@ fun FolderListScreen(
return
}
BackHandler(enabled = selection.active) { selection.clear() }
BackHandler(enabled = selection.active || uiState.moveMode) {
if (uiState.moveMode) {
viewModel.cancelMove()
} else {
selection.clear()
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
if (selection.active) {
Text(stringResource(R.string.selection_count, selection.ids.size))
} else {
Text(stringResource(R.string.files))
when {
uiState.moveMode -> Text(stringResource(R.string.move_pick_title, selection.ids.size))
selection.active -> Text(stringResource(R.string.selection_count, selection.ids.size))
else -> Text(stringResource(R.string.files))
}
},
navigationIcon = {
if (selection.active) {
IconButton(onClick = { selection.clear() }) {
if (uiState.moveMode || selection.active) {
IconButton(
onClick = {
if (uiState.moveMode) viewModel.cancelMove() else selection.clear()
},
) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.selection_cancel),
@@ -214,7 +232,7 @@ fun FolderListScreen(
}
},
actions = {
if (!selection.active) {
if (!uiState.moveMode && !selection.active) {
ServerStatusBadge(
status = connectionStatus,
onClick = connectionStatusViewModel::checkNow,
@@ -227,9 +245,17 @@ fun FolderListScreen(
)
},
bottomBar = {
if (selection.active) {
SelectionNavBar(
onMove = { showMoveDialog = true },
when {
uiState.moveMode -> MoveTargetBar(
fileCount = selection.ids.size,
onMoveHere = {
val ids = selection.ids.toList()
selection.clear()
viewModel.moveSelectionHere(ids)
},
)
selection.active -> SelectionNavBar(
onMove = { viewModel.startMove() },
onBuildPdf = {
val ids = selection.ids.toList()
selection.clear()
@@ -237,23 +263,39 @@ fun FolderListScreen(
},
enabled = selection.ids.isNotEmpty(),
)
} else {
FloatingNavBar(selected = selectedTab, onSelect = onTabSelected)
else -> FloatingNavBar(selected = selectedTab, onSelect = onTabSelected)
}
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { padding ->
FolderListContent(
uiState = uiState,
isImporting = importState.isImporting,
error = uiState.error ?: importState.error,
selection = selection,
onAddFolder = { pickFolderLauncher.launch(null) },
onCreateFolder = { showCreateDialog = true },
onOpenFolder = onOpenFolder,
onOpenDocument = onOpenDocument,
modifier = Modifier.padding(padding),
)
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize(),
) {
HomeViewSelector(
selected = uiState.view,
enabled = !selection.active && !uiState.moveMode,
onSelect = viewModel::selectView,
)
HomeViewContent(
view = uiState.view,
moveMode = uiState.moveMode,
atRoot = uiState.browseFolderId == null,
browseFolderName = uiState.browseFolderName,
subFolders = uiState.browseSubFolders,
sections = uiState.sections,
isImporting = importState.isImporting,
error = uiState.error ?: importState.error,
selection = selection,
onBrowseUp = viewModel::browseUp,
onOpenBrowseFolder = viewModel::openBrowseFolder,
onAddFolder = { pickFolderLauncher.launch(null) },
onCreateFolder = { showCreateDialog = true },
onOpenDocument = onOpenDocument,
modifier = Modifier.weight(1f),
)
}
}
if (showCreateDialog) {
@@ -261,27 +303,378 @@ fun FolderListScreen(
onDismiss = { showCreateDialog = false },
onConfirm = { name ->
showCreateDialog = false
viewModel.createFolderInDefaultRoot(name)
viewModel.createFolderInBrowse(name)
},
)
}
}
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)
},
/** Sélecteur de layer de la page Fichiers — même habillage pill que la nav flottante. */
@Composable
private fun HomeViewSelector(
selected: HomeView,
enabled: Boolean,
onSelect: (HomeView) -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(30.dp),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 8.dp,
modifier = Modifier
.widthIn(max = 400.dp)
.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(vertical = 6.dp, horizontal = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ViewSegment(
label = stringResource(R.string.view_files),
icon = Icons.Filled.Description,
selected = selected == HomeView.FILES,
enabled = enabled,
onClick = { onSelect(HomeView.FILES) },
modifier = Modifier.weight(1f),
)
ViewSegment(
label = stringResource(R.string.view_folders),
icon = Icons.Filled.Folder,
selected = selected == HomeView.FOLDERS,
enabled = enabled,
onClick = { onSelect(HomeView.FOLDERS) },
modifier = Modifier.weight(1f),
)
ViewSegment(
label = stringResource(R.string.view_dashboard),
icon = Icons.Filled.Dashboard,
selected = selected == HomeView.DASHBOARD,
enabled = enabled,
onClick = { onSelect(HomeView.DASHBOARD) },
modifier = Modifier.weight(1f),
)
}
}
}
}
@Composable
private fun ViewSegment(
label: String,
icon: ImageVector,
selected: Boolean,
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val tint = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
Surface(
onClick = onClick,
enabled = enabled,
shape = RoundedCornerShape(20.dp),
color = if (selected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface,
modifier = modifier,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(22.dp),
)
Text(
text = label,
color = tint,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
}
/** Routage du contenu selon le layer affiché. */
@Composable
private fun HomeViewContent(
view: HomeView,
moveMode: Boolean,
atRoot: Boolean,
browseFolderName: String?,
subFolders: List<FolderEntity>,
sections: List<FileSection>,
isImporting: Boolean,
error: String?,
selection: SelectionState,
onBrowseUp: () -> Unit,
onOpenBrowseFolder: (String) -> Unit,
onAddFolder: () -> Unit,
onCreateFolder: () -> Unit,
onOpenDocument: (String) -> Unit,
modifier: Modifier = Modifier,
) {
when (view) {
HomeView.DASHBOARD -> DashboardPlaceholder(modifier = modifier)
HomeView.FILES -> FileGridContent(
sections = sections,
isImporting = isImporting,
error = error,
selection = selection,
onOpenDocument = onOpenDocument,
modifier = modifier,
)
HomeView.FOLDERS -> FolderBrowserContent(
moveMode = moveMode,
atRoot = atRoot,
browseFolderName = browseFolderName,
subFolders = subFolders,
isImporting = isImporting,
error = error,
onBrowseUp = onBrowseUp,
onOpenBrowseFolder = onOpenBrowseFolder,
onAddFolder = onAddFolder,
onCreateFolder = onCreateFolder,
modifier = modifier,
)
}
}
/** Dashboard provisoire — vide pour l'instant. */
@Composable
private fun DashboardPlaceholder(modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.dashboard_coming_soon),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
/** Layer Fichiers : tous les fichiers visibles, groupés par jour. */
@Composable
private fun FileGridContent(
sections: List<FileSection>,
isImporting: Boolean,
error: String?,
selection: SelectionState,
onOpenDocument: (String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
if (isImporting) {
item(key = "importing") {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
}
}
error?.let { message ->
item(key = "error") {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
}
}
if (sections.isEmpty() && !isImporting) {
item(key = "empty") {
Text(
text = stringResource(R.string.no_files_yet),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
)
}
}
sections.forEach { section ->
item(key = "header-${section.dayKey}") {
SectionHeader(section.dayLabel)
}
items(section.rows, key = { it.key }) { row ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
FileCard(
file = row.left,
selection = selection,
onClick = { onOpenDocument(row.left.resourceId) },
modifier = Modifier.weight(1f),
)
if (row.right != null) {
FileCard(
file = row.right,
selection = selection,
onClick = { onOpenDocument(row.right.resourceId) },
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
}
}
}
}
}
/** Layer Dossiers : explorateur hiérarchique, en place sur la page. */
@Composable
private fun FolderBrowserContent(
moveMode: Boolean,
atRoot: Boolean,
browseFolderName: String?,
subFolders: List<FolderEntity>,
isImporting: Boolean,
error: String?,
onBrowseUp: () -> Unit,
onOpenBrowseFolder: (String) -> Unit,
onAddFolder: () -> Unit,
onCreateFolder: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
item(key = "breadcrumb") {
// La racine VaultDrop n'est pas affichée : on ne peut pas remonter au-dessus.
if (atRoot) {
Spacer(Modifier.height(8.dp))
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBrowseUp) {
Icon(
Icons.Filled.ArrowUpward,
contentDescription = stringResource(R.string.browse_up),
)
}
if (browseFolderName != null) {
Text(
text = browseFolderName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
}
}
if (!moveMode) {
item(key = "create") {
if (atRoot) {
Button(
onClick = onAddFolder,
enabled = !isImporting,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.add_folder))
}
}
OutlinedButton(
onClick = onCreateFolder,
enabled = !isImporting,
modifier = Modifier
.fillMaxWidth()
.padding(top = if (atRoot) 8.dp else 4.dp, bottom = 4.dp),
) {
Icon(Icons.Filled.CreateNewFolder, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.create_folder))
}
}
}
if (isImporting) {
item(key = "importing") {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
}
}
error?.let { message ->
item(key = "error") {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
}
}
items(subFolders, key = { it.resourceId }) { folder ->
FolderRow(folder, onClick = { onOpenBrowseFolder(folder.resourceId) })
}
if (subFolders.isEmpty() && !isImporting) {
item(key = "empty") {
Text(
text = stringResource(if (atRoot) R.string.no_folders_yet else R.string.empty_folder),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
)
}
}
}
}
@@ -353,126 +746,6 @@ private fun Uri.displayName(context: Context): String? = runCatching {
}
}.getOrNull()
@Composable
private fun FolderListContent(
uiState: FolderListUiState,
isImporting: Boolean,
error: String?,
selection: SelectionState,
onAddFolder: () -> Unit,
onCreateFolder: () -> Unit,
onOpenFolder: (String) -> Unit,
onOpenDocument: (String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
item(key = "actions") {
Button(
onClick = onAddFolder,
enabled = !isImporting,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.add_folder))
}
OutlinedButton(
onClick = onCreateFolder,
enabled = !isImporting,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
) {
Icon(Icons.Filled.CreateNewFolder, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.create_folder))
}
}
if (isImporting) {
item(key = "importing") {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
}
}
error?.let { message ->
item(key = "error") {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
}
}
if (uiState.subFolders.isNotEmpty()) {
item(key = "folders-header") {
SectionHeader(stringResource(R.string.folders_section))
}
items(uiState.subFolders, key = { it.resourceId }) { folder ->
FolderRow(folder, onClick = { onOpenFolder(folder.resourceId) })
}
}
if (uiState.sections.isEmpty() && !isImporting) {
item(key = "empty") {
Text(
text = stringResource(R.string.no_files_yet),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
)
}
}
uiState.sections.forEach { section ->
item(key = "header-${section.dayKey}") {
SectionHeader(section.dayLabel)
}
items(section.rows, key = { it.key }) { row ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
FileCard(
file = row.left,
selection = selection,
onClick = { onOpenDocument(row.left.resourceId) },
modifier = Modifier.weight(1f),
)
if (row.right != null) {
FileCard(
file = row.right,
selection = selection,
onClick = { onOpenDocument(row.right.resourceId) },
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
}
}
}
}
}
@Composable
private fun SectionHeader(label: String) {
Text(
@@ -484,7 +757,7 @@ private fun SectionHeader(label: String) {
)
}
/** Carte dossier de la section Dossiers — ouvre le dossier au tap. */
/** Carte dossier de l'explorateur — descend d'un niveau au tap. */
@Composable
private fun FolderRow(folder: FolderEntity, onClick: () -> Unit) {
Card(
@@ -3,6 +3,13 @@ package com.vaultdrop.mobile.ui.folderlist
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.local.entity.FolderEntity
/** Layer d'affichage de la page Fichiers. */
enum class HomeView {
FILES,
FOLDERS,
DASHBOARD,
}
/** Section d'un jour dans la grille d'accueil — miroir de `FileSection` (app/index.tsx). */
data class FileSection(
/** Début du jour en millis (fuseau local) — clé stable de la section. */
@@ -19,14 +26,25 @@ data class FilePair(
)
data class FolderListUiState(
val subFolders: List<FolderEntity> = emptyList(),
/** Segment actif de la page (Fichiers / Dossiers / Dashboard). */
val view: HomeView = HomeView.DASHBOARD,
/** Vrai pendant le déplacement d'une sélection : la vue Dossiers est forcée. */
val moveMode: Boolean = false,
/** Vue à restaurer après déplacement/annulation. */
val viewBeforeMove: HomeView? = null,
/** Dossier courant de l'explorateur Dossiers (null = racine par défaut). */
val browseFolderId: String? = null,
/** Nom du dossier courant (racine incluse) — fil d'ariane de l'explorateur. */
val browseFolderName: String? = null,
/** Sous-dossiers visibles du dossier courant de l'explorateur. */
val browseSubFolders: List<FolderEntity> = emptyList(),
val sections: List<FileSection> = emptyList(),
val isRefreshing: Boolean = false,
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,
/** Succès transitoire de déplacement — affiché en Snackbar puis effacé. */
val moveSuccess: Boolean = false,
)
@@ -8,7 +8,6 @@ import androidx.lifecycle.viewModelScope
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.auth.TokenProvider
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.local.entity.FolderEntity
import com.vaultdrop.mobile.data.local.referenceDate
import com.vaultdrop.mobile.data.preferences.DefaultRootStore
import com.vaultdrop.mobile.data.remote.ApiException
@@ -19,10 +18,12 @@ 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
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -52,10 +53,11 @@ class FolderListViewModel @Inject constructor(
private val _defaultRootId = MutableStateFlow(defaultRootStore.get())
val defaultRootId: StateFlow<String?> = _defaultRootId.asStateFlow()
private var subFolderJob: Job? = null
/** Dossier courant de l'explorateur Dossiers (null = racine par défaut). */
private val _browseFolderId = MutableStateFlow<String?>(null)
init {
observeSubFolders()
observeBrowse()
observeFiles()
refresh()
}
@@ -109,56 +111,92 @@ class FolderListViewModel @Inject constructor(
}
/**
* Sous-dossiers visibles de la racine par défaut, ré-abonnés quand la
* racine change (onboarding). Un dossier créé y apparaît immédiatement.
* Sélectionne le layer affiché de la page Fichiers. Désactivé en
* sélection/déplacement (ux : on ne change pas de vue dans ces modes).
*/
private fun observeSubFolders() {
fun selectView(view: HomeView) {
_uiState.update { it.copy(view = view) }
}
/**
* Explorateur Dossiers : sous-dossiers du dossier courant (`null` = racine
* par défaut), réabonnés quand la racine ou la position change. Quand la
* racine change (onboarding), on revient à la racine et on affiche son nom.
*/
private fun observeBrowse() {
viewModelScope.launch {
_defaultRootId.collect { rootId ->
subFolderJob?.cancel()
_browseFolderId.value = null
if (rootId == null) {
_uiState.update { it.copy(subFolders = emptyList()) }
return@collect
}
subFolderJob = viewModelScope.launch {
folderRepository.observeSubFolders(rootId).collect { subFolders ->
_uiState.update { it.copy(subFolders = subFolders) }
}
_uiState.update { it.copy(browseFolderId = null, browseFolderName = null) }
} else {
val rootName = folderRepository.getFolder(rootId)?.name
_uiState.update { it.copy(browseFolderId = null, browseFolderName = rootName) }
}
}
}
viewModelScope.launch {
_defaultRootId.combine(_browseFolderId) { root, browse -> browse ?: root }
.flatMapLatest { parentId ->
if (parentId == null) flowOf(emptyList())
else folderRepository.observeSubFolders(parentId)
}
.collect { subFolders ->
_uiState.update { it.copy(browseSubFolders = subFolders) }
}
}
}
/** Descend dans l'explorateur Dossiers (aussi en mode déplacement). */
fun openBrowseFolder(folderId: String) {
_browseFolderId.value = folderId
viewModelScope.launch {
val folder = folderRepository.getFolder(folderId)
_uiState.update { it.copy(browseFolderId = folderId, browseFolderName = folder?.name) }
}
}
/** Remonte au parent du dossier courant (reste à la racine si déjà en haut). */
fun browseUp() {
val current = _browseFolderId.value
viewModelScope.launch {
val parent = current?.let { folderRepository.getFolder(it)?.parentResourceId }
_browseFolderId.value = parent
val name = parent?.let { folderRepository.getFolder(it)?.name }
_uiState.update { it.copy(browseFolderId = parent, browseFolderName = name) }
}
}
/**
* Crée un dossier physique dans la racine par défaut (VaultDrop) puis
* l'enregistre en Room comme enfant de la racine.
* Crée un dossier physique dans le dossier courant de l'explorateur (racine
* par défaut si on est en haut) puis l'enregistre en Room comme son enfant.
*/
fun createFolderInDefaultRoot(name: String) {
fun createFolderInBrowse(name: String) {
val trimmed = name.trim()
if (trimmed.isBlank()) {
_uiState.update { it.copy(createError = context.getString(R.string.new_folder_name_required)) }
return
}
viewModelScope.launch {
val rootId = _defaultRootId.value
val root = rootId?.let { folderRepository.getFolder(it) }
if (root == null) {
val targetId = _browseFolderId.value ?: _defaultRootId.value
val target = targetId?.let { folderRepository.getFolder(it) }
if (target == null) {
_uiState.update { it.copy(createError = context.getString(R.string.new_folder_error)) }
return@launch
}
val message = if (root.uri == null) {
val message = if (target.uri == null) {
context.getString(R.string.new_folder_cloud_only)
} else {
context.getString(R.string.new_folder_error)
}
val created = safFolderCreator.createFolder(root.uri, trimmed)
val created = safFolderCreator.createFolder(target.uri, trimmed)
if (created == null) {
_uiState.update { it.copy(createError = message) }
return@launch
}
folderRepository.saveFolder(
input = SaveFolderInput(uri = created.toString(), name = trimmed, exists = true, createdInApp = true),
parentResourceId = root.resourceId,
parentResourceId = target.resourceId,
)
}
}
@@ -168,30 +206,60 @@ class FolderListViewModel @Inject constructor(
_uiState.update { it.copy(createError = null) }
}
/** Charge les dossiers éligibles pour le picker de déplacement. */
fun loadMoveFolders() {
/**
* Entre en mode déplacement : vue Dossiers forcée, explorateur ramené à la
* racine pour un choix de destination prévisible.
*/
fun startMove() {
_browseFolderId.value = null
viewModelScope.launch {
val moveFolders = folderRepository.getCreatedInApp()
_uiState.update { it.copy(moveFolders = moveFolders) }
val rootName = _defaultRootId.value?.let { folderRepository.getFolder(it)?.name }
_uiState.update {
it.copy(
viewBeforeMove = it.view,
view = HomeView.FOLDERS,
moveMode = true,
browseFolderId = null,
browseFolderName = rootName,
)
}
}
}
/** 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) }
/** Annule le mode déplacement : restaure la vue, la sélection est conservée. */
fun cancelMove() {
val previous = _uiState.value.viewBeforeMove ?: HomeView.DASHBOARD
_uiState.update {
it.copy(
moveMode = false,
view = previous,
viewBeforeMove = null,
)
}
}
/** Ferme le picker sans déplacer (annulation). */
fun closeMovePicker() {
_uiState.update { it.copy(moveFolders = null) }
/**
* Déplace la sélection dans le dossier courant de l'explorateur (la racine
* par défaut si on est en haut) puis sort du mode déplacement.
*/
fun moveSelectionHere(resourceIds: List<String>) {
viewModelScope.launch {
val target = _browseFolderId.value ?: _defaultRootId.value
if (target == null || runCatching { fileMover.moveFiles(resourceIds, target) }.isFailure) {
_uiState.update { it.copy(moveError = context.getString(R.string.move_files_error)) }
} else {
_uiState.update { it.copy(moveSuccess = true) }
}
val previous = _uiState.value.viewBeforeMove ?: HomeView.DASHBOARD
_uiState.update {
it.copy(moveMode = false, view = previous, viewBeforeMove = null)
}
}
}
/** Consomme le succès transitoire de déplacement (Snackbar). */
fun clearMoveSuccess() {
_uiState.update { it.copy(moveSuccess = false) }
}
fun clearMoveError() {
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
@@ -20,6 +21,7 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -166,6 +168,50 @@ fun SelectionNavBar(
}
}
/** Barre du mode déplacement : bouton « Déplacer ici (N) » vers le dossier courant. */
@Composable
fun MoveTargetBar(
fileCount: Int,
onMoveHere: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, bottom = 16.dp),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(30.dp),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 8.dp,
modifier = Modifier.widthIn(max = 400.dp).fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = onMoveHere,
modifier = Modifier
.weight(1f)
.heightIn(min = 52.dp),
) {
Icon(
imageVector = Icons.Filled.DriveFileMove,
contentDescription = null,
)
Text(
text = stringResource(R.string.move_here_count, fileCount),
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
}
}
@Composable
private fun SelectionAction(
label: String,
@@ -80,7 +80,6 @@ fun NavGraph(
selectedTab = selectedTab,
onTabSelected = onTabSelected,
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
onOpenFolder = { id -> navController.navigate(Routes.folder(id)) },
onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) },
syncViewModel = syncViewModel,
connectionStatusViewModel = connectionStatusViewModel,
@@ -3,6 +3,14 @@
<string name="files">Files</string>
<string name="folder">Folder</string>
<string name="view_files">Files</string>
<string name="view_folders">Folders</string>
<string name="view_dashboard">Dashboard</string>
<string name="dashboard_coming_soon">Dashboard coming soon</string>
<string name="browse_up">Up</string>
<string name="move_here_count">Move here (%1$d)</string>
<string name="move_pick_title">Move %1$d file(s)…</string>
<string name="move_success">File(s) moved</string>
<string name="default_root_title">Choose your VaultDrop folder</string>
<string name="default_root_message">VaultDrop needs a folder to store all the PDFs it creates. You can also create a new VaultDrop folder right in the picker. This step is required, once only.</string>
<string name="default_root_pick">Choose my VaultDrop folder</string>
@@ -8,6 +8,14 @@
<string name="default_root_folder_label">VaultDrop</string>
<string name="files">Fichiers</string>
<string name="folder">Dossier</string>
<string name="view_files">Fichiers</string>
<string name="view_folders">Dossiers</string>
<string name="view_dashboard">Dashboard</string>
<string name="dashboard_coming_soon">Dashboard à venir</string>
<string name="browse_up">Remonter</string>
<string name="move_here_count">Déplacer ici (%1$d)</string>
<string name="move_pick_title">Déplacer %1$d fichier(s)…</string>
<string name="move_success">Fichier(s) déplacé(s)</string>
<string name="add_folder">Ajouter un dossier</string>
<string name="create_folder">Créer un dossier</string>
<string name="new_folder_name_title">Nom du dossier</string>