From 4caa72385326d835adc90a01014dc896bd60ddcc Mon Sep 17 00:00:00 2001 From: m Date: Mon, 14 Sep 2026 15:45:44 +0200 Subject: [PATCH] share files --- .../mobile/ui/folderlist/FolderListScreen.kt | 82 ++++++++++++++++++- .../mobile/ui/folderlist/FolderListUiState.kt | 2 + .../ui/folderlist/FolderListViewModel.kt | 29 +++++++ .../mobile/ui/navigation/FloatingNavBar.kt | 12 ++- 4 files changed, 121 insertions(+), 4 deletions(-) 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 9f9d8a7..605534c 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 @@ -31,10 +31,14 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -72,6 +76,7 @@ import com.vaultdrop.mobile.data.local.entity.FileStatus import com.vaultdrop.mobile.data.local.entity.FolderEntity import com.vaultdrop.mobile.data.local.entity.FolderStatus import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel +import com.vaultdrop.mobile.features.connection.ServerConnectionStatus import com.vaultdrop.mobile.features.saf.safDisplayName import com.vaultdrop.mobile.features.sync.SyncViewModel import com.vaultdrop.mobile.features.saf.FileDeleter @@ -94,10 +99,18 @@ import com.vaultdrop.mobile.ui.navigation.FolderSelectionNavBar import com.vaultdrop.mobile.ui.navigation.MoveTargetBar import com.vaultdrop.mobile.ui.navigation.NavTab import com.vaultdrop.mobile.ui.navigation.SelectionNavBar +import com.vaultdrop.mobile.ui.share.ShareBottomSheet +import com.vaultdrop.mobile.ui.share.ShareViewModel import kotlinx.coroutines.delay import timber.log.Timber import java.util.Locale +/** Dossier cible du partage : id + nom affiché dans le sheet. */ +private data class ShareTarget( + val resourceId: String, + val name: String, +) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun FolderListScreen( @@ -110,6 +123,8 @@ fun FolderListScreen( viewModel: FolderListViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val shareViewModel: ShareViewModel = hiltViewModel() + val shareState by shareViewModel.uiState.collectAsStateWithLifecycle() val importState by syncViewModel.importState.collectAsStateWithLifecycle() val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() val defaultRootId by viewModel.defaultRootId.collectAsStateWithLifecycle() @@ -122,8 +137,18 @@ fun FolderListScreen( var pendingDeleteReview by remember { mutableStateOf(null) } var pendingFolderIds by remember { mutableStateOf>(emptyList()) } var pendingFileIds by remember { mutableStateOf>(emptyList()) } + var shareTarget by remember { mutableStateOf(null) } + var showShareMenu by remember { mutableStateOf(false) } + var shareEnabled by remember { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } + // Le partage multi-sélection ne vaut que pour un dossier unique sélectionné, + // possédé et déjà poussé au serveur (sinon dead-letter NOT_FOUND côté serveur). + LaunchedEffect(uiState.browseSubFolders, selection.ids) { + val folder = uiState.browseSubFolders.singleOrNull { it.resourceId in selection.ids } + shareEnabled = folder != null && viewModel.isShareableFolder(folder) + } + val createError = uiState.createError LaunchedEffect(createError) { if (createError != null) { @@ -164,6 +189,7 @@ fun FolderListScreen( // Racine par défaut : dossier VaultDrop choisi au premier lancement. val defaultRootLabel = stringResource(R.string.default_root_folder_label) + val folderLabel = stringResource(R.string.folder) var pendingDefaultPick by remember { mutableStateOf(null) } val pickDefaultRootLauncher = rememberLauncherForActivityResult( ActivityResultContracts.OpenDocumentTree(), @@ -253,6 +279,33 @@ fun FolderListScreen( IconButton(onClick = viewModel::refresh) { Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh)) } + if (uiState.view == HomeView.FOLDERS && connectionStatus != ServerConnectionStatus.Local) { + Box { + IconButton(onClick = { showShareMenu = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_actions)) + } + DropdownMenu( + expanded = showShareMenu, + onDismissRequest = { showShareMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.share_action)) }, + enabled = uiState.canShare, + leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) }, + onClick = { + showShareMenu = false + val targetId = uiState.browseFolderId ?: defaultRootId + if (targetId != null) { + shareTarget = ShareTarget( + resourceId = targetId, + name = uiState.browseFolderName ?: folderLabel, + ) + } + }, + ) + } + } + } } }, ) @@ -268,11 +321,18 @@ fun FolderListScreen( }, ) selection.active -> if (uiState.view == HomeView.FOLDERS) { + val selectedFolders = uiState.browseSubFolders.filter { + it.resourceId in selection.ids + } + val onlyFolder = selectedFolders.singleOrNull() FolderSelectionNavBar( - onDeleteModeSelected = { mode -> - val selectedFolders = uiState.browseSubFolders.filter { - it.resourceId in selection.ids + onShare = { + onlyFolder?.let { + shareTarget = ShareTarget(it.resourceId, it.name) } + }, + shareEnabled = shareEnabled, + onDeleteModeSelected = { mode -> val selectedFiles = uiState.browseFiles.filter { it.resourceId in selection.ids } @@ -423,6 +483,22 @@ fun FolderListScreen( }, ) } + + shareTarget?.let { target -> + ShareBottomSheet( + resourceName = target.name, + onDismiss = { + shareTarget = null + shareViewModel.reset() + }, + onShare = { username, access -> + shareViewModel.share(target.resourceId, "folder", username, access) + }, + sharing = shareState.sharing, + error = shareState.error, + enqueued = shareState.enqueued, + ) + } } /** Sélecteur de layer de la page Fichiers — même habillage pill que la nav flottante. */ diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt index 227c3eb..f9b1f6f 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt @@ -52,4 +52,6 @@ data class FolderListUiState( val deleteError: String? = null, /** Succès transitoire de suppression — affiché en Snackbar puis effacé. */ val deleteSuccess: Boolean = false, + /** Le dossier courant de l'explorateur peut être partagé (possédé + poussé). */ + val canShare: Boolean = false, ) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt index e576d61..a23de57 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt @@ -8,12 +8,14 @@ import androidx.lifecycle.viewModelScope import com.vaultdrop.mobile.R import com.vaultdrop.mobile.auth.TokenProvider import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.data.local.entity.FolderEntity import com.vaultdrop.mobile.data.local.referenceDate import com.vaultdrop.mobile.data.preferences.DefaultRootStore import com.vaultdrop.mobile.data.remote.ApiException import com.vaultdrop.mobile.data.repository.FileRepository import com.vaultdrop.mobile.data.repository.FolderRepository import com.vaultdrop.mobile.data.repository.SaveFolderInput +import com.vaultdrop.mobile.domain.DeviceIdentity import com.vaultdrop.mobile.features.saf.FileMover import com.vaultdrop.mobile.features.saf.FileDeleter import com.vaultdrop.mobile.features.saf.FolderDeleter @@ -47,6 +49,7 @@ class FolderListViewModel @Inject constructor( private val fileMover: FileMover, private val fileDeleter: FileDeleter, private val folderDeleter: FolderDeleter, + private val deviceIdentity: DeviceIdentity, @ApplicationContext private val context: Context, ) : ViewModel() { @@ -159,8 +162,34 @@ class FolderListViewModel @Inject constructor( _uiState.update { it.copy(browseFiles = files) } } } + viewModelScope.launch { + _defaultRootId.combine(_browseFolderId) { root, browse -> browse ?: root } + .collect { targetId -> + val shareable = targetId != null && + tokenProvider.current != null && + folderRepository.getFolder(targetId)?.let { isShareable(it) } == true + _uiState.update { it.copy(canShare = shareable) } + } + } } + /** + * Un dossier est partageable quand l'utilisateur est connecté et que ce + * n'est pas une ressource reçue en partage : `ownerId` absent ou égal à + * l'identité du device (les lignes hydratées portent l'utilisateur réel). + * + * Pas de gate `syncStatus` : un dossier `local` a son `create_resource` + * enqueued avant le `share` dans le même outbox — le serveur applique la + * création d'abord, le partage est donc valide. + */ + private suspend fun isShareable(folder: FolderEntity): Boolean { + if (tokenProvider.current == null) return false + val deviceUserId = deviceIdentity.getOrCreate() + return folder.ownerId == null || folder.ownerId == deviceUserId + } + + suspend fun isShareableFolder(folder: FolderEntity): Boolean = isShareable(folder) + /** Descend dans l'explorateur Dossiers (aussi en mode déplacement). */ fun openBrowseFolder(folderId: String) { _browseFolderId.value = folderId diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/FloatingNavBar.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/FloatingNavBar.kt index d414e4e..0853e90 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/FloatingNavBar.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/FloatingNavBar.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.MergeType import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.outlined.Dashboard import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Search @@ -180,9 +181,11 @@ fun SelectionNavBar( } } -/** Barre d'actions multi-sélection propre aux dossiers : suppression. */ +/** Barre d'actions multi-sélection propre aux dossiers : partage + suppression. */ @Composable fun FolderSelectionNavBar( + onShare: () -> Unit, + shareEnabled: Boolean, onDeleteModeSelected: (FileDeleter.DeleteMode) -> Unit, deleteEnabled: Boolean, modifier: Modifier = Modifier, @@ -204,6 +207,13 @@ fun FolderSelectionNavBar( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { + SelectionAction( + label = stringResource(R.string.share_action), + icon = Icons.Filled.Share, + enabled = shareEnabled, + onClick = onShare, + modifier = Modifier.weight(1f), + ) DeleteDropdownButton( enabled = deleteEnabled, onModeSelected = onDeleteModeSelected,