add fichiers filters
This commit is contained in:
+439
-166
@@ -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(
|
||||
|
||||
+21
-3
@@ -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,
|
||||
)
|
||||
+109
-41
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user