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.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
@@ -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),
@@ -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,
) )
@@ -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,58 +60,71 @@ fun NavGraph() {
} }
} }
NavHost( Box(modifier = Modifier.fillMaxSize()) {
navController = navController, NavHost(
startDestination = Routes.FILES, navController = navController,
) { startDestination = Routes.FILES,
composable(Routes.FILES) { ) {
FolderListScreen( composable(Routes.FILES) {
selectedTab = selectedTab, FolderListScreen(
onTabSelected = onTabSelected, selectedTab = selectedTab,
onOpenDocument = { id -> navController.navigate(Routes.document(id)) }, 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( // Bandeau global : visible sur tous les onglets tant qu'un import SAF
selectedTab = selectedTab, // est en cours (indique que la marche continue hors écran Fichiers).
onTabSelected = onTabSelected, if (importState.isImporting) {
onOpenDocument = { id -> navController.navigate(Routes.document(id)) }, LinearProgressIndicator(
) modifier = Modifier
} .align(Alignment.BottomCenter)
composable(Routes.SETTINGS) { .fillMaxWidth(),
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() },
) )
} }
} }
@@ -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)
} }
} }