From bada082c35b6ef428a90216db960fa04a284428b Mon Sep 17 00:00:00 2001 From: m Date: Sat, 12 Sep 2026 15:07:52 +0200 Subject: [PATCH] create folders --- .../ui/folderdetail/FolderDetailScreen.kt | 34 +++++++ .../ui/folderdetail/FolderDetailViewModel.kt | 50 ++++++++++ .../mobile/ui/folderlist/FolderListScreen.kt | 95 +++++++++++++++++++ .../mobile/ui/folderlist/FolderListUiState.kt | 4 + .../ui/folderlist/FolderListViewModel.kt | 67 +++++++++++++ .../mobile/ui/navigation/NavGraph.kt | 1 + .../app/src/main/res/values-en/strings.xml | 7 ++ .../app/src/main/res/values/strings.xml | 7 ++ 8 files changed, 265 insertions(+) diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt index fc2197a..55b1884 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt @@ -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 diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailViewModel.kt index 3498600..6ac1fe6 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailViewModel.kt @@ -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 = 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) } + } } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt index a255f37..653b562 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt @@ -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) -> 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 diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt index 52bd472..67f9a40 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt @@ -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 = emptyList(), val sections: List = 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, ) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt index 6614f65..c29d03a 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt @@ -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 = _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 { diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt index 9974d38..924f746 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt @@ -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, diff --git a/mobile-kotlin/app/src/main/res/values-en/strings.xml b/mobile-kotlin/app/src/main/res/values-en/strings.xml index b84b5e6..e9ae92a 100644 --- a/mobile-kotlin/app/src/main/res/values-en/strings.xml +++ b/mobile-kotlin/app/src/main/res/values-en/strings.xml @@ -8,6 +8,13 @@ Choose my VaultDrop folder VaultDrop Add a folder + Create a folder + Folder name + e.g. invoices + The folder name cannot be empty. + This folder is cloud-only — import a physical folder first to create subfolders inside it. + Could not create the folder. + Folders No folders yet. Tap "Add a folder" to sync a folder. No files yet. Tap "Add a folder" to sync your files. Empty folder — next sync will refresh it. diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index f7f8a87..c956cd2 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -9,6 +9,13 @@ Fichiers Dossier Ajouter un dossier + Créer un dossier + Nom du dossier + ex. factures + Le nom du dossier ne peut pas être vide. + Ce dossier est disponible uniquement sur le cloud — importe d\'abord un dossier physique pour y créer des sous-dossiers. + Impossible de créer le dossier. + Dossiers Aucun dossier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser un dossier. Aucun fichier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser tes fichiers. Dossier vide — le prochain syncDevice l\'actualisera.