load saf multi view

This commit is contained in:
m
2026-09-11 23:31:03 +02:00
parent 100bff994d
commit 4314770096
6 changed files with 137 additions and 84 deletions
@@ -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> = _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
@@ -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),
@@ -20,7 +20,5 @@ data class FilePair(
data class FolderListUiState(
val sections: List<FileSection> = 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,
)
@@ -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) }
}
}
}
@@ -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,6 +60,7 @@ fun NavGraph() {
}
}
Box(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = Routes.FILES,
@@ -59,6 +70,7 @@ fun NavGraph() {
selectedTab = selectedTab,
onTabSelected = onTabSelected,
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
syncViewModel = syncViewModel,
)
}
composable(Routes.SEARCH) {
@@ -105,4 +117,15 @@ fun NavGraph() {
)
}
}
// 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(),
)
}
}
}
@@ -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)
}
}