create folders

This commit is contained in:
m
2026-09-12 15:07:52 +02:00
parent 5f90cc9059
commit bada082c35
8 changed files with 265 additions and 0 deletions
@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.MergeType
import androidx.compose.material.icons.filled.Refresh
@@ -26,11 +27,16 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -44,6 +50,7 @@ import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.local.entity.FolderEntity
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
import com.vaultdrop.mobile.ui.components.FileCategoryIcon
import com.vaultdrop.mobile.ui.components.FolderNameDialog
import com.vaultdrop.mobile.ui.components.SelectionState
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
import com.vaultdrop.mobile.ui.components.ServerStatusBadge
@@ -64,11 +71,21 @@ fun FolderDetailScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
val selection = rememberSelectionState()
var showCreateDialog by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(uiState.folderMissing) {
if (uiState.folderMissing) onBack()
}
LaunchedEffect(uiState.createError) {
val createError = uiState.createError
if (createError != null) {
snackbarHostState.showSnackbar(createError)
viewModel.clearCreateError()
}
}
BackHandler(enabled = selection.active) { selection.clear() }
Scaffold(
@@ -114,6 +131,12 @@ fun FolderDetailScreen(
)
}
} else {
IconButton(onClick = { showCreateDialog = true }) {
Icon(
Icons.Filled.CreateNewFolder,
contentDescription = stringResource(R.string.create_folder),
)
}
ServerStatusBadge(
status = connectionStatus,
onClick = connectionStatusViewModel::checkNow,
@@ -125,6 +148,7 @@ fun FolderDetailScreen(
},
)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { padding ->
FolderDetailContent(
subFolders = uiState.subFolders,
@@ -137,6 +161,16 @@ fun FolderDetailScreen(
modifier = Modifier.padding(padding),
)
}
if (showCreateDialog) {
FolderNameDialog(
onDismiss = { showCreateDialog = false },
onConfirm = { name ->
showCreateDialog = false
viewModel.createFolder(name)
},
)
}
}
@Composable
@@ -1,14 +1,19 @@
package com.vaultdrop.mobile.ui.folderdetail
import android.content.Context
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
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.repository.FileRepository
import com.vaultdrop.mobile.data.repository.FolderRepository
import com.vaultdrop.mobile.data.repository.SaveFolderInput
import com.vaultdrop.mobile.features.saf.SafFolderCreator
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -23,6 +28,8 @@ data class FolderDetailUiState(
val files: List<FileEntity> = emptyList(),
val isRefreshing: Boolean = false,
val error: String? = null,
/** Erreur transitoire de création — affichée en Snackbar puis effacée. */
val createError: String? = null,
)
@HiltViewModel
@@ -31,6 +38,8 @@ class FolderDetailViewModel @Inject constructor(
private val folderRepository: FolderRepository,
private val fileRepository: FileRepository,
private val tokenProvider: TokenProvider,
private val safFolderCreator: SafFolderCreator,
@ApplicationContext private val context: Context,
) : ViewModel() {
private val folderResourceId: String =
@@ -78,4 +87,45 @@ class FolderDetailViewModel @Inject constructor(
_uiState.update { it.copy(isRefreshing = false) }
}
}
/**
* Crée un sous-dossier physique dans le dossier courant puis l'enregistre
* en Room. Impossible si le dossier parent est cloud-only (pas d'uri SAF).
*/
fun createFolder(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 folder = _uiState.value.folder
?: run {
_uiState.update { it.copy(createError = context.getString(R.string.new_folder_error)) }
return@launch
}
val physical = folder.uri != null
val created = safFolderCreator.createFolder(folder.uri, trimmed)
if (created == null) {
val message = if (physical) {
context.getString(R.string.new_folder_error)
} else {
context.getString(R.string.new_folder_cloud_only)
}
_uiState.update { it.copy(createError = message) }
return@launch
}
folderRepository.saveFolder(
input = SaveFolderInput(uri = created.toString(), name = trimmed, exists = true),
parentResourceId = folder.resourceId,
)
}
}
/** Consomme une erreur transitoire de création (Snackbar). */
fun clearCreateError() {
_uiState.update { it.copy(createError = null) }
}
}
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -28,6 +29,8 @@ 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.Close
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.MergeType
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
@@ -38,7 +41,10 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@@ -60,9 +66,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.local.entity.FolderEntity
import com.vaultdrop.mobile.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.SelectionState
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
import com.vaultdrop.mobile.ui.components.ServerStatusBadge
@@ -79,6 +87,7 @@ fun FolderListScreen(
selectedTab: NavTab,
onTabSelected: (NavTab) -> Unit,
onOpenDocument: (String) -> Unit,
onOpenFolder: (String) -> Unit,
onBuildPdf: (List<String>) -> Unit,
syncViewModel: SyncViewModel,
connectionStatusViewModel: ConnectionStatusViewModel,
@@ -91,6 +100,16 @@ fun FolderListScreen(
val context = LocalContext.current
val folderLabel = stringResource(R.string.folder)
val selection = rememberSelectionState()
var showCreateDialog by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val createError = uiState.createError
LaunchedEffect(createError) {
if (createError != null) {
snackbarHostState.showSnackbar(createError)
viewModel.clearCreateError()
}
}
// Racine par défaut : dossier VaultDrop choisi au premier lancement.
val defaultRootLabel = stringResource(R.string.default_root_folder_label)
@@ -210,6 +229,7 @@ fun FolderListScreen(
bottomBar = {
FloatingNavBar(selected = selectedTab, onSelect = onTabSelected)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { padding ->
FolderListContent(
uiState = uiState,
@@ -217,10 +237,22 @@ fun FolderListScreen(
error = uiState.error ?: importState.error,
selection = selection,
onAddFolder = { pickFolderLauncher.launch(null) },
onCreateFolder = { showCreateDialog = true },
onOpenFolder = onOpenFolder,
onOpenDocument = onOpenDocument,
modifier = Modifier.padding(padding),
)
}
if (showCreateDialog) {
FolderNameDialog(
onDismiss = { showCreateDialog = false },
onConfirm = { name ->
showCreateDialog = false
viewModel.createFolderInDefaultRoot(name)
},
)
}
}
/** Écran obligatoire du premier lancement : choix/création de la racine VaultDrop. */
@@ -298,6 +330,8 @@ private fun FolderListContent(
error: String?,
selection: SelectionState,
onAddFolder: () -> Unit,
onCreateFolder: () -> Unit,
onOpenFolder: (String) -> Unit,
onOpenDocument: (String) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -317,6 +351,17 @@ private fun FolderListContent(
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) {
@@ -342,6 +387,15 @@ private fun FolderListContent(
}
}
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(
@@ -400,6 +454,47 @@ private fun SectionHeader(label: String) {
)
}
/** Carte dossier de la section Dossiers — ouvre le dossier au tap. */
@Composable
private fun FolderRow(folder: FolderEntity, onClick: () -> Unit) {
Card(
onClick = onClick,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = folder.name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
fontWeight = FontWeight.Medium,
)
Text(
text = stringResource(R.string.folder),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/** Carte fichier — clic long pour la sélection multi-fichiers, clic pour ouvrir. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -1,6 +1,7 @@
package com.vaultdrop.mobile.ui.folderlist
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.local.entity.FolderEntity
/** Section d'un jour dans la grille d'accueil — miroir de `FileSection` (app/index.tsx). */
data class FileSection(
@@ -18,7 +19,10 @@ data class FilePair(
)
data class FolderListUiState(
val subFolders: 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,
)
@@ -8,14 +8,17 @@ 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
import com.vaultdrop.mobile.data.repository.FileRepository
import com.vaultdrop.mobile.data.repository.FolderRepository
import com.vaultdrop.mobile.data.repository.SaveFolderInput
import com.vaultdrop.mobile.features.saf.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
@@ -36,6 +39,7 @@ class FolderListViewModel @Inject constructor(
private val fileRepository: FileRepository,
private val tokenProvider: TokenProvider,
private val defaultRootStore: DefaultRootStore,
private val safFolderCreator: SafFolderCreator,
@ApplicationContext private val context: Context,
) : ViewModel() {
@@ -46,7 +50,10 @@ class FolderListViewModel @Inject constructor(
private val _defaultRootId = MutableStateFlow(defaultRootStore.get())
val defaultRootId: StateFlow<String?> = _defaultRootId.asStateFlow()
private var subFolderJob: Job? = null
init {
observeSubFolders()
observeFiles()
refresh()
}
@@ -94,6 +101,66 @@ class FolderListViewModel @Inject constructor(
return saved.resourceId
}
/**
* 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.
*/
private fun observeSubFolders() {
viewModelScope.launch {
_defaultRootId.collect { rootId ->
subFolderJob?.cancel()
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) }
}
}
}
}
}
/**
* Crée un dossier physique dans la racine par défaut (VaultDrop) puis
* l'enregistre en Room comme enfant de la racine.
*/
fun createFolderInDefaultRoot(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) {
_uiState.update { it.copy(createError = context.getString(R.string.new_folder_error)) }
return@launch
}
val message = if (root.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)
if (created == null) {
_uiState.update { it.copy(createError = message) }
return@launch
}
folderRepository.saveFolder(
input = SaveFolderInput(uri = created.toString(), name = trimmed, exists = true),
parentResourceId = root.resourceId,
)
}
}
/** Consomme une erreur transitoire de création (Snackbar). */
fun clearCreateError() {
_uiState.update { it.copy(createError = null) }
}
/** Grille d'accueil : tous les fichiers visibles, groupés par jour (date de référence). */
private fun observeFiles() {
viewModelScope.launch {
@@ -80,6 +80,7 @@ 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,
@@ -8,6 +8,13 @@
<string name="default_root_pick">Choose my VaultDrop folder</string>
<string name="default_root_folder_label">VaultDrop</string>
<string name="add_folder">Add a folder</string>
<string name="create_folder">Create a folder</string>
<string name="new_folder_name_title">Folder name</string>
<string name="new_folder_name_hint">e.g. invoices</string>
<string name="new_folder_name_required">The folder name cannot be empty.</string>
<string name="new_folder_cloud_only">This folder is cloud-only — import a physical folder first to create subfolders inside it.</string>
<string name="new_folder_error">Could not create the folder.</string>
<string name="folders_section">Folders</string>
<string name="no_folders_yet">No folders yet. Tap "Add a folder" to sync a folder.</string>
<string name="no_files_yet">No files yet. Tap "Add a folder" to sync your files.</string>
<string name="empty_folder">Empty folder — next sync will refresh it.</string>
@@ -9,6 +9,13 @@
<string name="files">Fichiers</string>
<string name="folder">Dossier</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>
<string name="new_folder_name_hint">ex. factures</string>
<string name="new_folder_name_required">Le nom du dossier ne peut pas être vide.</string>
<string name="new_folder_cloud_only">Ce dossier est disponible uniquement sur le cloud — importe d\'abord un dossier physique pour y créer des sous-dossiers.</string>
<string name="new_folder_error">Impossible de créer le dossier.</string>
<string name="folders_section">Dossiers</string>
<string name="no_folders_yet">Aucun dossier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser un dossier.</string>
<string name="no_files_yet">Aucun fichier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser tes fichiers.</string>
<string name="empty_folder">Dossier vide — le prochain syncDevice l\'actualisera.</string>