load saf multi view
This commit is contained in:
@@ -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
|
||||
|
||||
+14
-7
@@ -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),
|
||||
|
||||
-2
@@ -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,
|
||||
)
|
||||
-21
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user