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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
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.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@@ -24,10 +30,19 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SyncViewModel @Inject constructor(
|
class SyncViewModel @Inject constructor(
|
||||||
private val deviceSync: DeviceSync,
|
private val deviceSync: DeviceSync,
|
||||||
|
private val folderRepository: FolderRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private var loopJob: Job? = null
|
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). */
|
/** Démarre la boucle une seule fois (idempotent). */
|
||||||
fun ensureStarted() {
|
fun ensureStarted() {
|
||||||
if (loopJob?.isActive == true) return
|
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 {
|
companion object {
|
||||||
/** Cadence de la boucle — même valeur que `useSyncDevice(30_000)` Expo. */
|
/** Cadence de la boucle — même valeur que `useSyncDevice(30_000)` Expo. */
|
||||||
const val INTERVAL_MS = 30_000L
|
const val INTERVAL_MS = 30_000L
|
||||||
|
|||||||
+14
-7
@@ -51,6 +51,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vaultdrop.mobile.R
|
import com.vaultdrop.mobile.R
|
||||||
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
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.FloatingNavBar
|
||||||
import com.vaultdrop.mobile.ui.navigation.NavTab
|
import com.vaultdrop.mobile.ui.navigation.NavTab
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -61,9 +62,11 @@ fun FolderListScreen(
|
|||||||
selectedTab: NavTab,
|
selectedTab: NavTab,
|
||||||
onTabSelected: (NavTab) -> Unit,
|
onTabSelected: (NavTab) -> Unit,
|
||||||
onOpenDocument: (String) -> Unit,
|
onOpenDocument: (String) -> Unit,
|
||||||
|
syncViewModel: SyncViewModel,
|
||||||
viewModel: FolderListViewModel = hiltViewModel(),
|
viewModel: FolderListViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val importState by syncViewModel.importState.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val folderLabel = stringResource(R.string.folder)
|
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,
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
viewModel.savePickedFolder(
|
syncViewModel.importRoot(
|
||||||
uri = uri.toString(),
|
uri = uri.toString(),
|
||||||
name = uri.displayName(context) ?: uri.lastPathSegment ?: folderLabel,
|
name = uri.displayName(context) ?: uri.lastPathSegment ?: folderLabel,
|
||||||
)
|
)
|
||||||
@@ -101,6 +104,8 @@ fun FolderListScreen(
|
|||||||
) { padding ->
|
) { padding ->
|
||||||
FolderListContent(
|
FolderListContent(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
|
isImporting = importState.isImporting,
|
||||||
|
error = uiState.error ?: importState.error,
|
||||||
onAddFolder = { pickFolderLauncher.launch(null) },
|
onAddFolder = { pickFolderLauncher.launch(null) },
|
||||||
onOpenDocument = onOpenDocument,
|
onOpenDocument = onOpenDocument,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
@@ -126,6 +131,8 @@ private fun Uri.displayName(context: Context): String? = runCatching {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun FolderListContent(
|
private fun FolderListContent(
|
||||||
uiState: FolderListUiState,
|
uiState: FolderListUiState,
|
||||||
|
isImporting: Boolean,
|
||||||
|
error: String?,
|
||||||
onAddFolder: () -> Unit,
|
onAddFolder: () -> Unit,
|
||||||
onOpenDocument: (String) -> Unit,
|
onOpenDocument: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -137,7 +144,7 @@ private fun FolderListContent(
|
|||||||
item(key = "actions") {
|
item(key = "actions") {
|
||||||
Button(
|
Button(
|
||||||
onClick = onAddFolder,
|
onClick = onAddFolder,
|
||||||
enabled = !uiState.isScanning,
|
enabled = !isImporting,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 12.dp),
|
.padding(top = 12.dp),
|
||||||
@@ -148,8 +155,8 @@ private fun FolderListContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiState.isScanning) {
|
if (isImporting) {
|
||||||
item(key = "scanning") {
|
item(key = "importing") {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -158,10 +165,10 @@ private fun FolderListContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
uiState.error?.let { error ->
|
error?.let { message ->
|
||||||
item(key = "error") {
|
item(key = "error") {
|
||||||
Text(
|
Text(
|
||||||
text = error,
|
text = message,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -171,7 +178,7 @@ private fun FolderListContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiState.sections.isEmpty() && !uiState.isScanning) {
|
if (uiState.sections.isEmpty() && !isImporting) {
|
||||||
item(key = "empty") {
|
item(key = "empty") {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.no_files_yet),
|
text = stringResource(R.string.no_files_yet),
|
||||||
|
|||||||
-2
@@ -20,7 +20,5 @@ data class FilePair(
|
|||||||
data class FolderListUiState(
|
data class FolderListUiState(
|
||||||
val sections: List<FileSection> = emptyList(),
|
val sections: List<FileSection> = emptyList(),
|
||||||
val isRefreshing: Boolean = false,
|
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,
|
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.remote.ApiException
|
||||||
import com.vaultdrop.mobile.data.repository.FileRepository
|
import com.vaultdrop.mobile.data.repository.FileRepository
|
||||||
import com.vaultdrop.mobile.data.repository.FolderRepository
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -28,7 +26,6 @@ class FolderListViewModel @Inject constructor(
|
|||||||
private val folderRepository: FolderRepository,
|
private val folderRepository: FolderRepository,
|
||||||
private val fileRepository: FileRepository,
|
private val fileRepository: FileRepository,
|
||||||
private val tokenProvider: TokenProvider,
|
private val tokenProvider: TokenProvider,
|
||||||
private val deviceSync: DeviceSync,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(FolderListUiState())
|
private val _uiState = MutableStateFlow(FolderListUiState())
|
||||||
@@ -98,22 +95,4 @@ class FolderListViewModel @Inject constructor(
|
|||||||
_uiState.update { it.copy(isRefreshing = false) }
|
_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
|
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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
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.document.DocumentViewerScreen
|
||||||
import com.vaultdrop.mobile.ui.folderdetail.FolderDetailScreen
|
import com.vaultdrop.mobile.ui.folderdetail.FolderDetailScreen
|
||||||
import com.vaultdrop.mobile.ui.folderlist.FolderListScreen
|
import com.vaultdrop.mobile.ui.folderlist.FolderListScreen
|
||||||
@@ -35,12 +43,14 @@ private fun String?.toNavTab(): NavTab = when (this) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavGraph() {
|
fun NavGraph(syncViewModel: SyncViewModel) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val selectedTab = backStackEntry?.destination?.route.toNavTab()
|
val selectedTab = backStackEntry?.destination?.route.toNavTab()
|
||||||
|
|
||||||
|
val importState by syncViewModel.importState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val onTabSelected: (NavTab) -> Unit = { tab ->
|
val onTabSelected: (NavTab) -> Unit = { tab ->
|
||||||
if (tab.route != selectedTab.route) {
|
if (tab.route != selectedTab.route) {
|
||||||
navController.navigate(tab.route) {
|
navController.navigate(tab.route) {
|
||||||
@@ -50,6 +60,7 @@ fun NavGraph() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Routes.FILES,
|
startDestination = Routes.FILES,
|
||||||
@@ -59,6 +70,7 @@ fun NavGraph() {
|
|||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
onTabSelected = onTabSelected,
|
onTabSelected = onTabSelected,
|
||||||
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
|
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
|
||||||
|
syncViewModel = syncViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.SEARCH) {
|
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) {
|
when (authState) {
|
||||||
AuthState.Loading -> SplashScreen()
|
AuthState.Loading -> SplashScreen()
|
||||||
AuthState.SignedOut -> LoginScreen()
|
AuthState.SignedOut -> LoginScreen()
|
||||||
AuthState.Local -> NavGraph()
|
AuthState.Local -> NavGraph(syncViewModel = syncViewModel)
|
||||||
is AuthState.SignedIn -> NavGraph()
|
is AuthState.SignedIn -> NavGraph(syncViewModel = syncViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user