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 9969cf2..85a84d0 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 @@ -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) -> 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, + sections: List, + 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, + 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, + 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( 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 21c5319..08e6971 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 @@ -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 = 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 = 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, - /** Dossiers disponibles pour le picker de déplacement (null = pas chargé). */ - val moveFolders: List? = 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, ) \ 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 2b415cb..e2ab42c 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,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 = _defaultRootId.asStateFlow() - private var subFolderJob: Job? = null + /** Dossier courant de l'explorateur Dossiers (null = racine par défaut). */ + private val _browseFolderId = MutableStateFlow(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, 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) { + 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() { diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/FloatingNavBar.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/FloatingNavBar.kt index 098ba2a..3753383 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/FloatingNavBar.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/FloatingNavBar.kt @@ -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, 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 924f746..9974d38 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,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, 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 933b925..5261b65 100644 --- a/mobile-kotlin/app/src/main/res/values-en/strings.xml +++ b/mobile-kotlin/app/src/main/res/values-en/strings.xml @@ -3,6 +3,14 @@ Files Folder + Files + Folders + Dashboard + Dashboard coming soon + Up + Move here (%1$d) + Move %1$d file(s)… + File(s) moved Choose your VaultDrop folder 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. Choose my VaultDrop folder diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 0f1f827..417beb4 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -8,6 +8,14 @@ VaultDrop Fichiers Dossier + Fichiers + Dossiers + Dashboard + Dashboard à venir + Remonter + Déplacer ici (%1$d) + Déplacer %1$d fichier(s)… + Fichier(s) déplacé(s) Ajouter un dossier Créer un dossier Nom du dossier