diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt index 7c1081d..ba34e3c 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt @@ -2,9 +2,15 @@ package com.vaultdrop.mobile.features.sync import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vaultdrop.mobile.data.repository.FolderRepository +import com.vaultdrop.mobile.data.repository.SaveFolderInput import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import timber.log.Timber @@ -24,10 +30,19 @@ import javax.inject.Inject @HiltViewModel class SyncViewModel @Inject constructor( private val deviceSync: DeviceSync, + private val folderRepository: FolderRepository, ) : ViewModel() { private var loopJob: Job? = null + data class ImportState( + val isImporting: Boolean = false, + val error: String? = null, + ) + + private val _importState = MutableStateFlow(ImportState()) + val importState: StateFlow = _importState.asStateFlow() + /** Démarre la boucle une seule fois (idempotent). */ fun ensureStarted() { if (loopJob?.isActive == true) return @@ -46,6 +61,37 @@ class SyncViewModel @Inject constructor( } } + /** + * Import volontaire d'un dossier SAF (ajout + marche récursive). + * + * Scope Activity (VaultDropApp) : survit aux changements d'onglets — un + * walk lancé ici n'est pas annulé quand l'utilisateur quitte l'écran + * Fichiers, et `importState` continue d'être visible partout. + * + * Single-flight : tant qu'un import tourne (ou attend le mutex de + * [DeviceSync]), les appels suivants sont ignorés. + */ + fun importRoot(uri: String, name: String) { + if (_importState.value.isImporting) return + viewModelScope.launch { + _importState.value = ImportState(isImporting = true) + try { + val saved = folderRepository.saveFolder( + SaveFolderInput(uri = uri, name = name, exists = true), + ) + val result = deviceSync.syncRoot(saved.resourceId) + Timber.d("syncRoot %s", result) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "syncRoot failed") + _importState.value = ImportState(error = e.message ?: "Erreur lors de l'ajout du dossier") + } finally { + _importState.value = _importState.value.copy(isImporting = false) + } + } + } + companion object { /** Cadence de la boucle — même valeur que `useSyncDevice(30_000)` Expo. */ const val INTERVAL_MS = 30_000L 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 d99daed..c66f568 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 @@ -51,6 +51,7 @@ 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.features.sync.SyncViewModel import com.vaultdrop.mobile.ui.navigation.FloatingNavBar import com.vaultdrop.mobile.ui.navigation.NavTab import java.util.Locale @@ -61,9 +62,11 @@ fun FolderListScreen( selectedTab: NavTab, onTabSelected: (NavTab) -> Unit, onOpenDocument: (String) -> Unit, + syncViewModel: SyncViewModel, viewModel: FolderListViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val importState by syncViewModel.importState.collectAsStateWithLifecycle() val context = LocalContext.current val folderLabel = stringResource(R.string.folder) @@ -78,7 +81,7 @@ fun FolderListScreen( Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, ) } - viewModel.savePickedFolder( + syncViewModel.importRoot( uri = uri.toString(), name = uri.displayName(context) ?: uri.lastPathSegment ?: folderLabel, ) @@ -101,6 +104,8 @@ fun FolderListScreen( ) { padding -> FolderListContent( uiState = uiState, + isImporting = importState.isImporting, + error = uiState.error ?: importState.error, onAddFolder = { pickFolderLauncher.launch(null) }, onOpenDocument = onOpenDocument, modifier = Modifier.padding(padding), @@ -126,6 +131,8 @@ private fun Uri.displayName(context: Context): String? = runCatching { @Composable private fun FolderListContent( uiState: FolderListUiState, + isImporting: Boolean, + error: String?, onAddFolder: () -> Unit, onOpenDocument: (String) -> Unit, modifier: Modifier = Modifier, @@ -137,7 +144,7 @@ private fun FolderListContent( item(key = "actions") { Button( onClick = onAddFolder, - enabled = !uiState.isScanning, + enabled = !isImporting, modifier = Modifier .fillMaxWidth() .padding(top = 12.dp), @@ -148,8 +155,8 @@ private fun FolderListContent( } } - if (uiState.isScanning) { - item(key = "scanning") { + if (isImporting) { + item(key = "importing") { LinearProgressIndicator( modifier = Modifier .fillMaxWidth() @@ -158,10 +165,10 @@ private fun FolderListContent( } } - uiState.error?.let { error -> + error?.let { message -> item(key = "error") { Text( - text = error, + text = message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, modifier = Modifier @@ -171,7 +178,7 @@ private fun FolderListContent( } } - if (uiState.sections.isEmpty() && !uiState.isScanning) { + if (uiState.sections.isEmpty() && !isImporting) { item(key = "empty") { Text( text = stringResource(R.string.no_files_yet), 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 83b6df5..52bd472 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 @@ -20,7 +20,5 @@ data class FilePair( data class FolderListUiState( val sections: List = emptyList(), val isRefreshing: Boolean = false, - /** true pendant l'exploration SAF d'une racine (marche récursive en cours). */ - val isScanning: Boolean = false, val error: 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 6a96476..2ebdcb9 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 @@ -7,8 +7,6 @@ import com.vaultdrop.mobile.data.local.entity.FileEntity 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.sync.DeviceSync import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -28,7 +26,6 @@ class FolderListViewModel @Inject constructor( private val folderRepository: FolderRepository, private val fileRepository: FileRepository, private val tokenProvider: TokenProvider, - private val deviceSync: DeviceSync, ) : ViewModel() { private val _uiState = MutableStateFlow(FolderListUiState()) @@ -98,22 +95,4 @@ class FolderListViewModel @Inject constructor( _uiState.update { it.copy(isRefreshing = false) } } } - - /** Sélectionne un dossier SAF, le persiste puis explore récursivement. */ - fun savePickedFolder(uri: String, name: String) { - viewModelScope.launch { - _uiState.update { it.copy(isScanning = true, error = null) } - runCatching { - val saved = folderRepository.saveFolder( - SaveFolderInput(uri = uri, name = name, exists = true), - ) - val result = deviceSync.syncRoot(saved.resourceId) - Timber.d("syncRoot %s", result) - }.onFailure { e -> - Timber.w(e, "syncRoot failed") - _uiState.update { it.copy(error = e.message ?: "Erreur lors de l'ajout du dossier") } - } - _uiState.update { it.copy(isScanning = false) } - } - } } \ No newline at end of file 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 8ae9cad..e363810 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 @@ -1,13 +1,21 @@ package com.vaultdrop.mobile.ui.navigation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vaultdrop.mobile.features.sync.SyncViewModel import com.vaultdrop.mobile.ui.document.DocumentViewerScreen import com.vaultdrop.mobile.ui.folderdetail.FolderDetailScreen import com.vaultdrop.mobile.ui.folderlist.FolderListScreen @@ -35,12 +43,14 @@ private fun String?.toNavTab(): NavTab = when (this) { } @Composable -fun NavGraph() { +fun NavGraph(syncViewModel: SyncViewModel) { val navController = rememberNavController() val backStackEntry by navController.currentBackStackEntryAsState() val selectedTab = backStackEntry?.destination?.route.toNavTab() + val importState by syncViewModel.importState.collectAsStateWithLifecycle() + val onTabSelected: (NavTab) -> Unit = { tab -> if (tab.route != selectedTab.route) { navController.navigate(tab.route) { @@ -50,58 +60,71 @@ fun NavGraph() { } } - NavHost( - navController = navController, - startDestination = Routes.FILES, - ) { - composable(Routes.FILES) { - FolderListScreen( - selectedTab = selectedTab, - onTabSelected = onTabSelected, - onOpenDocument = { id -> navController.navigate(Routes.document(id)) }, - ) + Box(modifier = Modifier.fillMaxSize()) { + NavHost( + navController = navController, + startDestination = Routes.FILES, + ) { + composable(Routes.FILES) { + FolderListScreen( + selectedTab = selectedTab, + onTabSelected = onTabSelected, + onOpenDocument = { id -> navController.navigate(Routes.document(id)) }, + syncViewModel = syncViewModel, + ) + } + composable(Routes.SEARCH) { + SearchScreen( + selectedTab = selectedTab, + onTabSelected = onTabSelected, + onOpenDocument = { id -> navController.navigate(Routes.document(id)) }, + ) + } + composable(Routes.SETTINGS) { + SettingsScreen( + selectedTab = selectedTab, + onTabSelected = onTabSelected, + ) + } + composable( + route = Routes.FOLDER_DETAIL, + arguments = listOf( + navArgument(Routes.ARG_FOLDER) { type = NavType.StringType }, + ), + ) { backStackEntry -> + val folderId = checkNotNull( + backStackEntry.arguments?.getString(Routes.ARG_FOLDER), + ) + FolderDetailScreen( + folderResourceId = folderId, + onBack = { navController.popBackStack() }, + onOpenFolder = { id -> navController.navigate(Routes.folder(id)) }, + onOpenDocument = { id -> navController.navigate(Routes.document(id)) }, + ) + } + composable( + route = Routes.DOCUMENT, + arguments = listOf( + navArgument(Routes.ARG_DOCUMENT) { type = NavType.StringType }, + ), + ) { backStackEntry -> + val documentId = checkNotNull( + backStackEntry.arguments?.getString(Routes.ARG_DOCUMENT), + ) + DocumentViewerScreen( + initialResourceId = documentId, + onBack = { navController.popBackStack() }, + ) + } } - composable(Routes.SEARCH) { - SearchScreen( - selectedTab = selectedTab, - onTabSelected = onTabSelected, - onOpenDocument = { id -> navController.navigate(Routes.document(id)) }, - ) - } - composable(Routes.SETTINGS) { - SettingsScreen( - selectedTab = selectedTab, - onTabSelected = onTabSelected, - ) - } - composable( - route = Routes.FOLDER_DETAIL, - arguments = listOf( - navArgument(Routes.ARG_FOLDER) { type = NavType.StringType }, - ), - ) { backStackEntry -> - val folderId = checkNotNull( - backStackEntry.arguments?.getString(Routes.ARG_FOLDER), - ) - FolderDetailScreen( - folderResourceId = folderId, - onBack = { navController.popBackStack() }, - onOpenFolder = { id -> navController.navigate(Routes.folder(id)) }, - onOpenDocument = { id -> navController.navigate(Routes.document(id)) }, - ) - } - composable( - route = Routes.DOCUMENT, - arguments = listOf( - navArgument(Routes.ARG_DOCUMENT) { type = NavType.StringType }, - ), - ) { backStackEntry -> - val documentId = checkNotNull( - backStackEntry.arguments?.getString(Routes.ARG_DOCUMENT), - ) - DocumentViewerScreen( - initialResourceId = documentId, - onBack = { navController.popBackStack() }, + + // Bandeau global : visible sur tous les onglets tant qu'un import SAF + // est en cours (indique que la marche continue hors écran Fichiers). + if (importState.isImporting) { + LinearProgressIndicator( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), ) } } diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/VaultDropApp.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/VaultDropApp.kt index c9b7939..29eb675 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/VaultDropApp.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/VaultDropApp.kt @@ -46,7 +46,7 @@ fun VaultDropApp( when (authState) { AuthState.Loading -> SplashScreen() AuthState.SignedOut -> LoginScreen() - AuthState.Local -> NavGraph() - is AuthState.SignedIn -> NavGraph() + AuthState.Local -> NavGraph(syncViewModel = syncViewModel) + is AuthState.SignedIn -> NavGraph(syncViewModel = syncViewModel) } } \ No newline at end of file