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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Add 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.Close
import androidx.compose.material.icons.filled.CreateNewFolder 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.Folder
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -44,6 +48,7 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
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
@@ -55,12 +60,14 @@ 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
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R 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.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
import com.vaultdrop.mobile.ui.components.rememberSelectionState import com.vaultdrop.mobile.ui.components.rememberSelectionState
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar 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.NavTab
import com.vaultdrop.mobile.ui.navigation.SelectionNavBar import com.vaultdrop.mobile.ui.navigation.SelectionNavBar
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -88,7 +95,6 @@ fun FolderListScreen(
selectedTab: NavTab, selectedTab: NavTab,
onTabSelected: (NavTab) -> Unit, onTabSelected: (NavTab) -> Unit,
onOpenDocument: (String) -> Unit, onOpenDocument: (String) -> Unit,
onOpenFolder: (String) -> Unit,
onBuildPdf: (List<String>) -> Unit, onBuildPdf: (List<String>) -> Unit,
syncViewModel: SyncViewModel, syncViewModel: SyncViewModel,
connectionStatusViewModel: ConnectionStatusViewModel, connectionStatusViewModel: ConnectionStatusViewModel,
@@ -102,7 +108,6 @@ 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
@@ -121,8 +126,11 @@ fun FolderListScreen(
} }
} }
LaunchedEffect(showMoveDialog) { LaunchedEffect(uiState.moveSuccess) {
if (showMoveDialog) viewModel.loadMoveFolders() if (uiState.moveSuccess) {
snackbarHostState.showSnackbar(context.getString(R.string.move_success))
viewModel.clearMoveSuccess()
}
} }
// Racine par défaut : dossier VaultDrop choisi au premier lancement. // Racine par défaut : dossier VaultDrop choisi au premier lancement.
@@ -191,21 +199,31 @@ fun FolderListScreen(
return return
} }
BackHandler(enabled = selection.active) { selection.clear() } BackHandler(enabled = selection.active || uiState.moveMode) {
if (uiState.moveMode) {
viewModel.cancelMove()
} else {
selection.clear()
}
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
if (selection.active) { when {
Text(stringResource(R.string.selection_count, selection.ids.size)) uiState.moveMode -> Text(stringResource(R.string.move_pick_title, selection.ids.size))
} else { selection.active -> Text(stringResource(R.string.selection_count, selection.ids.size))
Text(stringResource(R.string.files)) else -> Text(stringResource(R.string.files))
} }
}, },
navigationIcon = { navigationIcon = {
if (selection.active) { if (uiState.moveMode || selection.active) {
IconButton(onClick = { selection.clear() }) { IconButton(
onClick = {
if (uiState.moveMode) viewModel.cancelMove() else selection.clear()
},
) {
Icon( Icon(
Icons.Filled.Close, Icons.Filled.Close,
contentDescription = stringResource(R.string.selection_cancel), contentDescription = stringResource(R.string.selection_cancel),
@@ -214,7 +232,7 @@ fun FolderListScreen(
} }
}, },
actions = { actions = {
if (!selection.active) { if (!uiState.moveMode && !selection.active) {
ServerStatusBadge( ServerStatusBadge(
status = connectionStatus, status = connectionStatus,
onClick = connectionStatusViewModel::checkNow, onClick = connectionStatusViewModel::checkNow,
@@ -227,9 +245,17 @@ fun FolderListScreen(
) )
}, },
bottomBar = { bottomBar = {
if (selection.active) { when {
SelectionNavBar( uiState.moveMode -> MoveTargetBar(
onMove = { showMoveDialog = true }, fileCount = selection.ids.size,
onMoveHere = {
val ids = selection.ids.toList()
selection.clear()
viewModel.moveSelectionHere(ids)
},
)
selection.active -> SelectionNavBar(
onMove = { viewModel.startMove() },
onBuildPdf = { onBuildPdf = {
val ids = selection.ids.toList() val ids = selection.ids.toList()
selection.clear() selection.clear()
@@ -237,23 +263,39 @@ fun FolderListScreen(
}, },
enabled = selection.ids.isNotEmpty(), enabled = selection.ids.isNotEmpty(),
) )
} else { else -> FloatingNavBar(selected = selectedTab, onSelect = onTabSelected)
FloatingNavBar(selected = selectedTab, onSelect = onTabSelected)
} }
}, },
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { padding -> ) { padding ->
FolderListContent( Column(
uiState = uiState, modifier = Modifier
isImporting = importState.isImporting, .padding(padding)
error = uiState.error ?: importState.error, .fillMaxSize(),
selection = selection, ) {
onAddFolder = { pickFolderLauncher.launch(null) }, HomeViewSelector(
onCreateFolder = { showCreateDialog = true }, selected = uiState.view,
onOpenFolder = onOpenFolder, enabled = !selection.active && !uiState.moveMode,
onOpenDocument = onOpenDocument, onSelect = viewModel::selectView,
modifier = Modifier.padding(padding), )
) 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) { if (showCreateDialog) {
@@ -261,27 +303,378 @@ fun FolderListScreen(
onDismiss = { showCreateDialog = false }, onDismiss = { showCreateDialog = false },
onConfirm = { name -> onConfirm = { name ->
showCreateDialog = false showCreateDialog = false
viewModel.createFolderInDefaultRoot(name) viewModel.createFolderInBrowse(name)
}, },
) )
} }
}
val moveFolders = uiState.moveFolders /** Sélecteur de layer de la page Fichiers — même habillage pill que la nav flottante. */
if (showMoveDialog && moveFolders != null) { @Composable
MoveFolderPickerDialog( private fun HomeViewSelector(
folders = moveFolders, selected: HomeView,
fileCount = selection.ids.size, enabled: Boolean,
onDismiss = { onSelect: (HomeView) -> Unit,
showMoveDialog = false modifier: Modifier = Modifier,
viewModel.closeMovePicker() ) {
}, Box(
onConfirm = { folderId -> modifier = modifier
val ids = selection.ids.toList() .fillMaxWidth()
selection.clear() .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp),
showMoveDialog = false contentAlignment = Alignment.Center,
viewModel.moveSelectedFiles(ids, folderId) ) {
}, 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() }.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 @Composable
private fun SectionHeader(label: String) { private fun SectionHeader(label: String) {
Text( 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 @Composable
private fun FolderRow(folder: FolderEntity, onClick: () -> Unit) { private fun FolderRow(folder: FolderEntity, onClick: () -> Unit) {
Card( 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.FileEntity
import com.vaultdrop.mobile.data.local.entity.FolderEntity 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). */ /** Section d'un jour dans la grille d'accueil — miroir de `FileSection` (app/index.tsx). */
data class FileSection( data class FileSection(
/** Début du jour en millis (fuseau local) — clé stable de la section. */ /** Début du jour en millis (fuseau local) — clé stable de la section. */
@@ -19,14 +26,25 @@ data class FilePair(
) )
data class FolderListUiState( 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 sections: List<FileSection> = emptyList(),
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
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. */ /** Erreur transitoire de déplacement — affichée en Snackbar puis effacée. */
val moveError: String? = null, 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.R
import com.vaultdrop.mobile.auth.TokenProvider import com.vaultdrop.mobile.auth.TokenProvider
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.local.referenceDate import com.vaultdrop.mobile.data.local.referenceDate
import com.vaultdrop.mobile.data.preferences.DefaultRootStore import com.vaultdrop.mobile.data.preferences.DefaultRootStore
import com.vaultdrop.mobile.data.remote.ApiException 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 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
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@@ -52,10 +53,11 @@ class FolderListViewModel @Inject constructor(
private val _defaultRootId = MutableStateFlow(defaultRootStore.get()) private val _defaultRootId = MutableStateFlow(defaultRootStore.get())
val defaultRootId: StateFlow<String?> = _defaultRootId.asStateFlow() 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 { init {
observeSubFolders() observeBrowse()
observeFiles() observeFiles()
refresh() refresh()
} }
@@ -109,56 +111,92 @@ class FolderListViewModel @Inject constructor(
} }
/** /**
* Sous-dossiers visibles de la racine par défaut, ré-abonnés quand la * Sélectionne le layer affiché de la page Fichiers. Désactivé en
* racine change (onboarding). Un dossier créé y apparaît immédiatement. * 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 { viewModelScope.launch {
_defaultRootId.collect { rootId -> _defaultRootId.collect { rootId ->
subFolderJob?.cancel() _browseFolderId.value = null
if (rootId == null) { if (rootId == null) {
_uiState.update { it.copy(subFolders = emptyList()) } _uiState.update { it.copy(browseFolderId = null, browseFolderName = null) }
return@collect } else {
} val rootName = folderRepository.getFolder(rootId)?.name
subFolderJob = viewModelScope.launch { _uiState.update { it.copy(browseFolderId = null, browseFolderName = rootName) }
folderRepository.observeSubFolders(rootId).collect { subFolders ->
_uiState.update { it.copy(subFolders = subFolders) }
}
} }
} }
} }
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 * Crée un dossier physique dans le dossier courant de l'explorateur (racine
* l'enregistre en Room comme enfant de la 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() val trimmed = name.trim()
if (trimmed.isBlank()) { if (trimmed.isBlank()) {
_uiState.update { it.copy(createError = context.getString(R.string.new_folder_name_required)) } _uiState.update { it.copy(createError = context.getString(R.string.new_folder_name_required)) }
return return
} }
viewModelScope.launch { viewModelScope.launch {
val rootId = _defaultRootId.value val targetId = _browseFolderId.value ?: _defaultRootId.value
val root = rootId?.let { folderRepository.getFolder(it) } val target = targetId?.let { folderRepository.getFolder(it) }
if (root == null) { if (target == null) {
_uiState.update { it.copy(createError = context.getString(R.string.new_folder_error)) } _uiState.update { it.copy(createError = context.getString(R.string.new_folder_error)) }
return@launch return@launch
} }
val message = if (root.uri == null) { val message = if (target.uri == null) {
context.getString(R.string.new_folder_cloud_only) context.getString(R.string.new_folder_cloud_only)
} else { } else {
context.getString(R.string.new_folder_error) context.getString(R.string.new_folder_error)
} }
val created = safFolderCreator.createFolder(root.uri, trimmed) val created = safFolderCreator.createFolder(target.uri, trimmed)
if (created == null) { if (created == null) {
_uiState.update { it.copy(createError = message) } _uiState.update { it.copy(createError = message) }
return@launch return@launch
} }
folderRepository.saveFolder( folderRepository.saveFolder(
input = SaveFolderInput(uri = created.toString(), name = trimmed, exists = true, createdInApp = true), 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) } _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 { viewModelScope.launch {
val moveFolders = folderRepository.getCreatedInApp() val rootName = _defaultRootId.value?.let { folderRepository.getFolder(it)?.name }
_uiState.update { it.copy(moveFolders = moveFolders) } _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. */ /** Annule le mode déplacement : restaure la vue, la sélection est conservée. */
fun moveSelectedFiles(resourceIds: List<String>, targetFolderId: String) { fun cancelMove() {
viewModelScope.launch { val previous = _uiState.value.viewBeforeMove ?: HomeView.DASHBOARD
runCatching { fileMover.moveFiles(resourceIds, targetFolderId) } _uiState.update {
.onFailure { it.copy(
_uiState.update { state -> moveMode = false,
state.copy(moveError = context.getString(R.string.move_files_error)) view = previous,
} viewBeforeMove = null,
} )
_uiState.update { it.copy(moveFolders = null) }
} }
} }
/** Ferme le picker sans déplacer (annulation). */ /**
fun closeMovePicker() { * Déplace la sélection dans le dossier courant de l'explorateur (la racine
_uiState.update { it.copy(moveFolders = null) } * 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() { fun clearMoveError() {
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn 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.Folder
import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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 @Composable
private fun SelectionAction( private fun SelectionAction(
label: String, label: String,
@@ -80,7 +80,6 @@ fun NavGraph(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = onTabSelected, onTabSelected = onTabSelected,
onOpenDocument = { id -> navController.navigate(Routes.document(id)) }, onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
onOpenFolder = { id -> navController.navigate(Routes.folder(id)) },
onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) }, onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) },
syncViewModel = syncViewModel, syncViewModel = syncViewModel,
connectionStatusViewModel = connectionStatusViewModel, connectionStatusViewModel = connectionStatusViewModel,
@@ -3,6 +3,14 @@
<string name="files">Files</string> <string name="files">Files</string>
<string name="folder">Folder</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_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_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> <string name="default_root_pick">Choose my VaultDrop folder</string>
@@ -8,6 +8,14 @@
<string name="default_root_folder_label">VaultDrop</string> <string name="default_root_folder_label">VaultDrop</string>
<string name="files">Fichiers</string> <string name="files">Fichiers</string>
<string name="folder">Dossier</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="add_folder">Ajouter un dossier</string>
<string name="create_folder">Créer un dossier</string> <string name="create_folder">Créer un dossier</string>
<string name="new_folder_name_title">Nom du dossier</string> <string name="new_folder_name_title">Nom du dossier</string>