add saf config
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+3
@@ -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.
|
||||||
@@ -39,3 +41,18 @@ object SafUris {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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()
|
||||||
+73
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-55
@@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
+189
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user