From 7cc8c6eb24398c8a4fd81ef504502c88e405a444 Mon Sep 17 00:00:00 2001 From: m Date: Sat, 12 Sep 2026 20:48:00 +0200 Subject: [PATCH] add saf config --- .../mobile/data/local/dao/FolderDao.kt | 4 + .../data/repository/FolderRepository.kt | 3 + .../vaultdrop/mobile/features/saf/SafUris.kt | 19 +- .../ui/components/WatchedFoldersBadge.kt | 73 +++++++ .../mobile/ui/folderlist/FolderListScreen.kt | 58 +----- .../mobile/ui/navigation/NavGraph.kt | 11 + .../mobile/ui/settings/SettingsScreen.kt | 21 +- .../ui/watchedfolders/WatchedFoldersScreen.kt | 189 ++++++++++++++++++ .../watchedfolders/WatchedFoldersViewModel.kt | 28 +++ .../app/src/main/res/values-en/strings.xml | 5 + .../app/src/main/res/values/strings.xml | 5 + 11 files changed, 359 insertions(+), 57 deletions(-) create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/WatchedFoldersBadge.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/watchedfolders/WatchedFoldersScreen.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/watchedfolders/WatchedFoldersViewModel.kt diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt index 163592c..a65522b 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt @@ -24,6 +24,10 @@ interface FolderDao { @Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC") fun observeRootFolders(): Flow> + /** Racines physiques surveillées (uri SAF non nulle) — distinctes des racines cloud-only. */ + @Query("SELECT * FROM folders WHERE parent_resource_id IS NULL AND uri IS NOT NULL ORDER BY name ASC") + fun observeSafRoots(): Flow> + @Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC") suspend fun getRootFolders(): List diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt index d84abd0..250d3a7 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt @@ -25,6 +25,9 @@ class FolderRepository @Inject constructor( fun observeRootFolders(): Flow> = folderDao.observeRootFolders() + /** Racines SAF surveillées par le sync (celles qui déclenchent un walk périodique). */ + fun observeSafRoots(): Flow> = folderDao.observeSafRoots() + /** Sous-dossiers visibles d'un dossier (filtre `exists = 1` dans le DAO). */ fun observeSubFolders(parentResourceId: String): Flow> = folderDao.observeByParent(parentResourceId) diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafUris.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafUris.kt index 0f4a630..4be95a1 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafUris.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafUris.kt @@ -1,8 +1,10 @@ package com.vaultdrop.mobile.features.saf import android.content.ContentResolver +import android.content.Context import android.net.Uri import android.provider.DocumentsContract +import android.provider.OpenableColumns /** * Conversions d'URI SAF partagées par SafFolderCreator et FileMover. @@ -38,4 +40,19 @@ object SafUris { raw } } -} \ No newline at end of file +} + +/** Nom affiché d'une racine SAF via DocumentsContract (colonne DISPLAY_NAME). */ +fun Uri.safDisplayName(context: Context): String? = runCatching { + val docId = DocumentsContract.getTreeDocumentId(this) + val docUri = DocumentsContract.buildDocumentUriUsingTree(this, docId) + context.contentResolver.query( + docUri, + arrayOf(OpenableColumns.DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } +}.getOrNull() \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/WatchedFoldersBadge.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/WatchedFoldersBadge.kt new file mode 100644 index 0000000..d0e02b4 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/WatchedFoldersBadge.kt @@ -0,0 +1,73 @@ +package com.vaultdrop.mobile.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vaultdrop.mobile.R +import com.vaultdrop.mobile.ui.watchedfolders.WatchedFoldersViewModel + +/** Seuil au-delà duquel le badge affiche « 99+ » au lieu du compte exact. */ +private const val COUNT_CAP = 99 + +/** + * Pilule du header affichant le nombre de dossiers SAF surveillés. Un appui + * ouvre l'écran listant ces dossiers. + */ +@Composable +fun WatchedFoldersBadge( + onClick: () -> Unit, + viewModel: WatchedFoldersViewModel = hiltViewModel(), +) { + val count by viewModel.count.collectAsStateWithLifecycle(initialValue = 0) + val label = if (count > COUNT_CAP) { + stringResource(R.string.watched_count_many) + } else { + count.toString() + } + val contentDescription = stringResource(R.string.settings_saf_folders) + + Row( + modifier = Modifier + .clip(RoundedCornerShape(percent = 50)) + .clickable( + role = Role.Button, + onClickLabel = contentDescription, + onClick = onClick, + ) + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Filled.Visibility, + contentDescription = contentDescription, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} \ No newline at end of file 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 d4f82f6..ca96bf2 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 @@ -1,10 +1,7 @@ package com.vaultdrop.mobile.ui.folderlist -import android.content.Context import android.content.Intent import android.net.Uri -import android.provider.DocumentsContract -import android.provider.OpenableColumns import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -73,6 +70,7 @@ import com.vaultdrop.mobile.R import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.data.local.entity.FolderEntity import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel +import com.vaultdrop.mobile.features.saf.safDisplayName import com.vaultdrop.mobile.features.sync.SyncViewModel import com.vaultdrop.mobile.ui.components.FileCategoryIcon import com.vaultdrop.mobile.ui.components.FolderNameDialog @@ -104,7 +102,6 @@ fun FolderListScreen( val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() val defaultRootId by viewModel.defaultRootId.collectAsStateWithLifecycle() val context = LocalContext.current - val folderLabel = stringResource(R.string.folder) val selection = rememberSelectionState() var showCreateDialog by remember { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } @@ -146,7 +143,7 @@ fun FolderListScreen( ) }.onFailure { Timber.w(it, "persistable permission absent on default root pick") } pendingDefaultPick = uri - val rootName = uri.displayName(context) ?: uri.lastPathSegment ?: defaultRootLabel + val rootName = uri.safDisplayName(context) ?: uri.lastPathSegment ?: defaultRootLabel syncViewModel.importRoot(uri = uri.toString(), name = rootName) } @@ -172,23 +169,6 @@ fun FolderListScreen( pendingDefaultPick = null } - val pickFolderLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocumentTree(), - ) { uri: Uri? -> - if (uri == null) return@rememberLauncherForActivityResult - // Permissions persistantes : l'app ré-ouvrira le dossier aux prochains lancements. - runCatching { - context.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, - ) - } - syncViewModel.importRoot( - uri = uri.toString(), - name = uri.displayName(context) ?: uri.lastPathSegment ?: folderLabel, - ) - } - if (defaultRootId == null) { DefaultRootOnboarding( importing = importState.isImporting, @@ -289,7 +269,6 @@ fun FolderListScreen( selection = selection, onBrowseUp = viewModel::browseUp, onOpenBrowseFolder = viewModel::openBrowseFolder, - onAddFolder = { pickFolderLauncher.launch(null) }, onCreateFolder = { showCreateDialog = true }, onOpenDocument = onOpenDocument, modifier = Modifier.weight(1f), @@ -416,7 +395,6 @@ private fun HomeViewContent( selection: SelectionState, onBrowseUp: () -> Unit, onOpenBrowseFolder: (String) -> Unit, - onAddFolder: () -> Unit, onCreateFolder: () -> Unit, onOpenDocument: (String) -> Unit, modifier: Modifier = Modifier, @@ -439,7 +417,6 @@ private fun HomeViewContent( error = error, onBrowseUp = onBrowseUp, onOpenBrowseFolder = onOpenBrowseFolder, - onAddFolder = onAddFolder, onCreateFolder = onCreateFolder, modifier = modifier, ) @@ -541,7 +518,6 @@ private fun FolderBrowserContent( error: String?, onBrowseUp: () -> Unit, onOpenBrowseFolder: (String) -> Unit, - onAddFolder: () -> Unit, onCreateFolder: () -> Unit, modifier: Modifier = Modifier, ) { @@ -582,25 +558,12 @@ private fun FolderBrowserContent( if (!moveMode) { item(key = "create") { - if (atRoot) { - Button( - onClick = onAddFolder, - enabled = !isImporting, - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - ) { - Icon(Icons.Filled.Add, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.add_folder)) - } - } OutlinedButton( onClick = onCreateFolder, enabled = !isImporting, modifier = Modifier .fillMaxWidth() - .padding(top = if (atRoot) 8.dp else 4.dp, bottom = 4.dp), + .padding(top = 8.dp, bottom = 4.dp), ) { Icon(Icons.Filled.CreateNewFolder, contentDescription = null) Spacer(Modifier.width(8.dp)) @@ -705,21 +668,6 @@ private fun DefaultRootOnboarding( } } -/** Nom affiché d'un dossier SAF via DocumentsContract (DISPLAY_NAME). */ -private fun Uri.displayName(context: Context): String? = runCatching { - val docId = DocumentsContract.getTreeDocumentId(this) - val docUri = DocumentsContract.buildDocumentUriUsingTree(this, docId) - context.contentResolver.query( - docUri, - arrayOf(OpenableColumns.DISPLAY_NAME), - null, - null, - null, - )?.use { cursor -> - if (cursor.moveToFirst()) cursor.getString(0) else null - } -}.getOrNull() - @Composable private fun SectionHeader(label: String) { Text( 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 4514e54..31fed6e 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 @@ -27,6 +27,7 @@ import com.vaultdrop.mobile.ui.pdfbuilder.PdfBuilderScreen import com.vaultdrop.mobile.ui.review.SwipeReviewScreen import com.vaultdrop.mobile.ui.search.SearchScreen import com.vaultdrop.mobile.ui.settings.SettingsScreen +import com.vaultdrop.mobile.ui.watchedfolders.WatchedFoldersScreen object Routes { const val FILES = "files" @@ -34,6 +35,7 @@ object Routes { const val SETTINGS = "settings" const val DASHBOARD = "dashboard" const val REVIEW = "review" + const val WATCHED_FOLDERS = "watched" const val FOLDER_DETAIL = "folder/{folderResourceId}" const val ARG_FOLDER = "folderResourceId" const val DOCUMENT = "document/{documentResourceId}" @@ -67,6 +69,8 @@ fun NavGraph( val importState by syncViewModel.importState.collectAsStateWithLifecycle() + val onOpenWatchedFolders = { navController.navigate(Routes.WATCHED_FOLDERS) } + val onTabSelected: (NavTab) -> Unit = { tab -> if (tab.route != selectedTab.route) { navController.navigate(tab.route) { @@ -108,6 +112,7 @@ fun NavGraph( SettingsScreen( selectedTab = selectedTab, onTabSelected = onTabSelected, + onOpenWatchedFolders = onOpenWatchedFolders, authViewModel = authViewModel, connectionStatusViewModel = connectionStatusViewModel, ) @@ -126,6 +131,12 @@ fun NavGraph( connectionStatusViewModel = connectionStatusViewModel, ) } + composable(Routes.WATCHED_FOLDERS) { + WatchedFoldersScreen( + onBack = { navController.popBackStack() }, + syncViewModel = syncViewModel, + ) + } composable( route = Routes.FOLDER_DETAIL, arguments = listOf( diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsScreen.kt index 350ad9e..2974662 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsScreen.kt @@ -1,5 +1,6 @@ package com.vaultdrop.mobile.ui.settings +import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -14,6 +15,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Person import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -51,6 +53,7 @@ import com.vaultdrop.mobile.ui.auth.AuthState import com.vaultdrop.mobile.ui.auth.AuthViewModel import com.vaultdrop.mobile.ui.auth.authErrorResFor import com.vaultdrop.mobile.ui.components.ServerStatusBadge +import com.vaultdrop.mobile.ui.components.WatchedFoldersBadge import com.vaultdrop.mobile.ui.navigation.FloatingNavBar import com.vaultdrop.mobile.ui.navigation.NavTab @@ -59,6 +62,7 @@ import com.vaultdrop.mobile.ui.navigation.NavTab fun SettingsScreen( selectedTab: NavTab, onTabSelected: (NavTab) -> Unit, + onOpenWatchedFolders: () -> Unit, authViewModel: AuthViewModel, connectionStatusViewModel: ConnectionStatusViewModel, viewModel: SettingsViewModel = hiltViewModel(), @@ -119,6 +123,17 @@ fun SettingsScreen( }, ) + HorizontalDivider() + SettingsRow( + icon = { Icon(Icons.Filled.Folder, contentDescription = null) }, + title = stringResource(R.string.settings_watched_folders), + subtitle = stringResource(R.string.settings_watched_folders_hint), + trailing = { + WatchedFoldersBadge(onClick = onOpenWatchedFolders) + }, + onClick = onOpenWatchedFolders, + ) + HorizontalDivider() SettingsSectionTitle(stringResource(R.string.settings_server)) @@ -241,6 +256,8 @@ fun SettingsScreen( } } } + + HorizontalDivider() Spacer(Modifier.height(16.dp)) } } @@ -251,11 +268,13 @@ private fun SettingsRow( icon: @Composable () -> Unit, title: String, subtitle: String, - trailing: @Composable () -> Unit, + trailing: @Composable () -> Unit = {}, + onClick: (() -> Unit)? = null, ) { Row( modifier = Modifier .fillMaxWidth() + .clickable(enabled = onClick != null, onClick = { onClick?.invoke() }) .padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/watchedfolders/WatchedFoldersScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/watchedfolders/WatchedFoldersScreen.kt new file mode 100644 index 0000000..8d92ac3 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/watchedfolders/WatchedFoldersScreen.kt @@ -0,0 +1,189 @@ +package com.vaultdrop.mobile.ui.watchedfolders + +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vaultdrop.mobile.R +import com.vaultdrop.mobile.data.local.entity.FolderEntity +import com.vaultdrop.mobile.features.saf.safDisplayName +import com.vaultdrop.mobile.features.sync.SyncViewModel + +/** + * Dossiers SAF surveillés par l'application. D'ici on ajoute une racine + * (picker OpenDocumentTree) et on consulte la liste des racines déjà suivies. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WatchedFoldersScreen( + onBack: () -> Unit, + syncViewModel: SyncViewModel, + viewModel: WatchedFoldersViewModel = hiltViewModel(), +) { + val folders by viewModel.folders.collectAsStateWithLifecycle(initialValue = emptyList()) + val importState by syncViewModel.importState.collectAsStateWithLifecycle() + + val context = LocalContext.current + val folderLabel = stringResource(R.string.folder) + val pickFolderLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree(), + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + // Permissions persistantes : l'app ré-ouvrira le dossier aux prochains lancements. + runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + syncViewModel.importRoot( + uri = uri.toString(), + name = uri.safDisplayName(context) ?: uri.lastPathSegment ?: folderLabel, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.settings_saf_folders)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + item(key = "add") { + Button( + onClick = { pickFolderLauncher.launch(null) }, + enabled = !importState.isImporting, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) { + Icon(Icons.Filled.Add, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.add_folder)) + } + } + + if (importState.isImporting) { + item(key = "importing") { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) + } + } + + items(folders, key = { it.resourceId }) { root -> + WatchedFolderRow(root) + HorizontalDivider() + } + + if (folders.isEmpty() && !importState.isImporting) { + item(key = "empty") { + Text( + text = stringResource(R.string.settings_saf_folders_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + ) + } + } + } + } +} + +@Composable +private fun WatchedFolderRow(folder: FolderEntity) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Filled.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .padding(8.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = folder.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + folder.uri?.let { uri -> + Text( + text = uri, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/watchedfolders/WatchedFoldersViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/watchedfolders/WatchedFoldersViewModel.kt new file mode 100644 index 0000000..61efa0d --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/watchedfolders/WatchedFoldersViewModel.kt @@ -0,0 +1,28 @@ +package com.vaultdrop.mobile.ui.watchedfolders + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vaultdrop.mobile.data.local.entity.FolderEntity +import com.vaultdrop.mobile.data.repository.FolderRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** État des dossiers SAF surveillés, partagé entre le badge du header et l'écran de liste. */ +@HiltViewModel +class WatchedFoldersViewModel @Inject constructor( + folderRepository: FolderRepository, +) : ViewModel() { + + /** Liste live des racines SAF surveillées (uri non nulle). */ + val folders: Flow> = folderRepository.observeSafRoots() + + /** Nombre de racines surveillées — l'UI plafonne l'affichage à 99 (« 99+ »). */ + val count: StateFlow = folderRepository.observeSafRoots() + .map { it.size } + .stateIn(viewModelScope, SharingStarted.Eagerly, 0) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/res/values-en/strings.xml b/mobile-kotlin/app/src/main/res/values-en/strings.xml index f998b17..ae978c6 100644 --- a/mobile-kotlin/app/src/main/res/values-en/strings.xml +++ b/mobile-kotlin/app/src/main/res/values-en/strings.xml @@ -71,6 +71,11 @@ Sign in Sign out Signed in as %1$s + Watched SAF folders + No watched folders yet. + Watched system folders + View watched folders + 99+ Search diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 1479395..cffeddc 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -88,6 +88,11 @@ Se connecter Se déconnecter Connecté en tant que %1$s + Dossiers SAF surveillés + Aucun dossier surveillé pour le moment. + Dossiers système surveillés + Consulter les dossiers surveillés + 99+ Recherche