add saf config

This commit is contained in:
m
2026-09-12 20:48:00 +02:00
parent ec99d1ac18
commit 7cc8c6eb24
11 changed files with 359 additions and 57 deletions
@@ -24,6 +24,10 @@ interface FolderDao {
@Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC") @Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC")
fun observeRootFolders(): Flow<List<FolderEntity>> fun observeRootFolders(): Flow<List<FolderEntity>>
/** 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<List<FolderEntity>>
@Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC") @Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC")
suspend fun getRootFolders(): List<FolderEntity> suspend fun getRootFolders(): List<FolderEntity>
@@ -25,6 +25,9 @@ class FolderRepository @Inject constructor(
fun observeRootFolders(): Flow<List<FolderEntity>> = folderDao.observeRootFolders() fun observeRootFolders(): Flow<List<FolderEntity>> = folderDao.observeRootFolders()
/** Racines SAF surveillées par le sync (celles qui déclenchent un walk périodique). */
fun observeSafRoots(): Flow<List<FolderEntity>> = folderDao.observeSafRoots()
/** Sous-dossiers visibles d'un dossier (filtre `exists = 1` dans le DAO). */ /** Sous-dossiers visibles d'un dossier (filtre `exists = 1` dans le DAO). */
fun observeSubFolders(parentResourceId: String): Flow<List<FolderEntity>> = fun observeSubFolders(parentResourceId: String): Flow<List<FolderEntity>> =
folderDao.observeByParent(parentResourceId) folderDao.observeByParent(parentResourceId)
@@ -1,8 +1,10 @@
package com.vaultdrop.mobile.features.saf package com.vaultdrop.mobile.features.saf
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.OpenableColumns
/** /**
* Conversions d'URI SAF partagées par SafFolderCreator et FileMover. * Conversions d'URI SAF partagées par SafFolderCreator et FileMover.
@@ -38,4 +40,19 @@ object SafUris {
raw raw
} }
} }
} }
/** 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()
@@ -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,
)
}
}
@@ -1,10 +1,7 @@
package com.vaultdrop.mobile.ui.folderlist package com.vaultdrop.mobile.ui.folderlist
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.FileEntity
import com.vaultdrop.mobile.data.local.entity.FolderEntity import com.vaultdrop.mobile.data.local.entity.FolderEntity
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel 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.features.sync.SyncViewModel
import com.vaultdrop.mobile.ui.components.FileCategoryIcon import com.vaultdrop.mobile.ui.components.FileCategoryIcon
import com.vaultdrop.mobile.ui.components.FolderNameDialog import com.vaultdrop.mobile.ui.components.FolderNameDialog
@@ -104,7 +102,6 @@ fun FolderListScreen(
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
val defaultRootId by viewModel.defaultRootId.collectAsStateWithLifecycle() val defaultRootId by viewModel.defaultRootId.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val folderLabel = stringResource(R.string.folder)
val selection = rememberSelectionState() val selection = rememberSelectionState()
var showCreateDialog by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -146,7 +143,7 @@ fun FolderListScreen(
) )
}.onFailure { Timber.w(it, "persistable permission absent on default root pick") } }.onFailure { Timber.w(it, "persistable permission absent on default root pick") }
pendingDefaultPick = uri 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) syncViewModel.importRoot(uri = uri.toString(), name = rootName)
} }
@@ -172,23 +169,6 @@ fun FolderListScreen(
pendingDefaultPick = null 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) { if (defaultRootId == null) {
DefaultRootOnboarding( DefaultRootOnboarding(
importing = importState.isImporting, importing = importState.isImporting,
@@ -289,7 +269,6 @@ fun FolderListScreen(
selection = selection, selection = selection,
onBrowseUp = viewModel::browseUp, onBrowseUp = viewModel::browseUp,
onOpenBrowseFolder = viewModel::openBrowseFolder, onOpenBrowseFolder = viewModel::openBrowseFolder,
onAddFolder = { pickFolderLauncher.launch(null) },
onCreateFolder = { showCreateDialog = true }, onCreateFolder = { showCreateDialog = true },
onOpenDocument = onOpenDocument, onOpenDocument = onOpenDocument,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -416,7 +395,6 @@ private fun HomeViewContent(
selection: SelectionState, selection: SelectionState,
onBrowseUp: () -> Unit, onBrowseUp: () -> Unit,
onOpenBrowseFolder: (String) -> Unit, onOpenBrowseFolder: (String) -> Unit,
onAddFolder: () -> Unit,
onCreateFolder: () -> Unit, onCreateFolder: () -> Unit,
onOpenDocument: (String) -> Unit, onOpenDocument: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -439,7 +417,6 @@ private fun HomeViewContent(
error = error, error = error,
onBrowseUp = onBrowseUp, onBrowseUp = onBrowseUp,
onOpenBrowseFolder = onOpenBrowseFolder, onOpenBrowseFolder = onOpenBrowseFolder,
onAddFolder = onAddFolder,
onCreateFolder = onCreateFolder, onCreateFolder = onCreateFolder,
modifier = modifier, modifier = modifier,
) )
@@ -541,7 +518,6 @@ private fun FolderBrowserContent(
error: String?, error: String?,
onBrowseUp: () -> Unit, onBrowseUp: () -> Unit,
onOpenBrowseFolder: (String) -> Unit, onOpenBrowseFolder: (String) -> Unit,
onAddFolder: () -> Unit,
onCreateFolder: () -> Unit, onCreateFolder: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -582,25 +558,12 @@ private fun FolderBrowserContent(
if (!moveMode) { if (!moveMode) {
item(key = "create") { 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( OutlinedButton(
onClick = onCreateFolder, onClick = onCreateFolder,
enabled = !isImporting, enabled = !isImporting,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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) Icon(Icons.Filled.CreateNewFolder, contentDescription = null)
Spacer(Modifier.width(8.dp)) 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 @Composable
private fun SectionHeader(label: String) { private fun SectionHeader(label: String) {
Text( Text(
@@ -27,6 +27,7 @@ import com.vaultdrop.mobile.ui.pdfbuilder.PdfBuilderScreen
import com.vaultdrop.mobile.ui.review.SwipeReviewScreen import com.vaultdrop.mobile.ui.review.SwipeReviewScreen
import com.vaultdrop.mobile.ui.search.SearchScreen import com.vaultdrop.mobile.ui.search.SearchScreen
import com.vaultdrop.mobile.ui.settings.SettingsScreen import com.vaultdrop.mobile.ui.settings.SettingsScreen
import com.vaultdrop.mobile.ui.watchedfolders.WatchedFoldersScreen
object Routes { object Routes {
const val FILES = "files" const val FILES = "files"
@@ -34,6 +35,7 @@ object Routes {
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val DASHBOARD = "dashboard" const val DASHBOARD = "dashboard"
const val REVIEW = "review" const val REVIEW = "review"
const val WATCHED_FOLDERS = "watched"
const val FOLDER_DETAIL = "folder/{folderResourceId}" const val FOLDER_DETAIL = "folder/{folderResourceId}"
const val ARG_FOLDER = "folderResourceId" const val ARG_FOLDER = "folderResourceId"
const val DOCUMENT = "document/{documentResourceId}" const val DOCUMENT = "document/{documentResourceId}"
@@ -67,6 +69,8 @@ fun NavGraph(
val importState by syncViewModel.importState.collectAsStateWithLifecycle() val importState by syncViewModel.importState.collectAsStateWithLifecycle()
val onOpenWatchedFolders = { navController.navigate(Routes.WATCHED_FOLDERS) }
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) {
@@ -108,6 +112,7 @@ fun NavGraph(
SettingsScreen( SettingsScreen(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = onTabSelected, onTabSelected = onTabSelected,
onOpenWatchedFolders = onOpenWatchedFolders,
authViewModel = authViewModel, authViewModel = authViewModel,
connectionStatusViewModel = connectionStatusViewModel, connectionStatusViewModel = connectionStatusViewModel,
) )
@@ -126,6 +131,12 @@ fun NavGraph(
connectionStatusViewModel = connectionStatusViewModel, connectionStatusViewModel = connectionStatusViewModel,
) )
} }
composable(Routes.WATCHED_FOLDERS) {
WatchedFoldersScreen(
onBack = { navController.popBackStack() },
syncViewModel = syncViewModel,
)
}
composable( composable(
route = Routes.FOLDER_DETAIL, route = Routes.FOLDER_DETAIL,
arguments = listOf( arguments = listOf(
@@ -1,5 +1,6 @@
package com.vaultdrop.mobile.ui.settings package com.vaultdrop.mobile.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -14,6 +15,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator 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.AuthViewModel
import com.vaultdrop.mobile.ui.auth.authErrorResFor import com.vaultdrop.mobile.ui.auth.authErrorResFor
import com.vaultdrop.mobile.ui.components.ServerStatusBadge 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.FloatingNavBar
import com.vaultdrop.mobile.ui.navigation.NavTab import com.vaultdrop.mobile.ui.navigation.NavTab
@@ -59,6 +62,7 @@ import com.vaultdrop.mobile.ui.navigation.NavTab
fun SettingsScreen( fun SettingsScreen(
selectedTab: NavTab, selectedTab: NavTab,
onTabSelected: (NavTab) -> Unit, onTabSelected: (NavTab) -> Unit,
onOpenWatchedFolders: () -> Unit,
authViewModel: AuthViewModel, authViewModel: AuthViewModel,
connectionStatusViewModel: ConnectionStatusViewModel, connectionStatusViewModel: ConnectionStatusViewModel,
viewModel: SettingsViewModel = hiltViewModel(), 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() HorizontalDivider()
SettingsSectionTitle(stringResource(R.string.settings_server)) SettingsSectionTitle(stringResource(R.string.settings_server))
@@ -241,6 +256,8 @@ fun SettingsScreen(
} }
} }
} }
HorizontalDivider()
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
} }
} }
@@ -251,11 +268,13 @@ private fun SettingsRow(
icon: @Composable () -> Unit, icon: @Composable () -> Unit,
title: String, title: String,
subtitle: String, subtitle: String,
trailing: @Composable () -> Unit, trailing: @Composable () -> Unit = {},
onClick: (() -> Unit)? = null,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -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,
)
}
}
}
}
@@ -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<List<FolderEntity>> = folderRepository.observeSafRoots()
/** Nombre de racines surveillées — l'UI plafonne l'affichage à 99 (« 99+ »). */
val count: StateFlow<Int> = folderRepository.observeSafRoots()
.map { it.size }
.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
}
@@ -71,6 +71,11 @@
<string name="settings_connect">Sign in</string> <string name="settings_connect">Sign in</string>
<string name="settings_disconnect">Sign out</string> <string name="settings_disconnect">Sign out</string>
<string name="settings_connected_as">Signed in as %1$s</string> <string name="settings_connected_as">Signed in as %1$s</string>
<string name="settings_saf_folders">Watched SAF folders</string>
<string name="settings_saf_folders_empty">No watched folders yet.</string>
<string name="settings_watched_folders">Watched system folders</string>
<string name="settings_watched_folders_hint">View watched folders</string>
<string name="watched_count_many">99+</string>
<!-- Search --> <!-- Search -->
<string name="search">Search</string> <string name="search">Search</string>
@@ -88,6 +88,11 @@
<string name="settings_connect">Se connecter</string> <string name="settings_connect">Se connecter</string>
<string name="settings_disconnect">Se déconnecter</string> <string name="settings_disconnect">Se déconnecter</string>
<string name="settings_connected_as">Connecté en tant que %1$s</string> <string name="settings_connected_as">Connecté en tant que %1$s</string>
<string name="settings_saf_folders">Dossiers SAF surveillés</string>
<string name="settings_saf_folders_empty">Aucun dossier surveillé pour le moment.</string>
<string name="settings_watched_folders">Dossiers système surveillés</string>
<string name="settings_watched_folders_hint">Consulter les dossiers surveillés</string>
<string name="watched_count_many">99+</string>
<!-- Recherche --> <!-- Recherche -->
<string name="search">Recherche</string> <string name="search">Recherche</string>