share files

This commit is contained in:
m
2026-09-14 15:45:44 +02:00
parent 03837edb72
commit 4caa723853
4 changed files with 121 additions and 4 deletions
@@ -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<DeleteReview?>(null) }
var pendingFolderIds by remember { mutableStateOf<List<String>>(emptyList()) }
var pendingFileIds by remember { mutableStateOf<List<String>>(emptyList()) }
var shareTarget by remember { mutableStateOf<ShareTarget?>(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<Uri?>(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) {
FolderSelectionNavBar(
onDeleteModeSelected = { mode ->
val selectedFolders = uiState.browseSubFolders.filter {
it.resourceId in selection.ids
}
val onlyFolder = selectedFolders.singleOrNull()
FolderSelectionNavBar(
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. */
@@ -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,
)
@@ -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,7 +162,33 @@ 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) {
@@ -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,