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.CreateNewFolder
import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Folder 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.Refresh
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton 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.FolderEntity
import com.vaultdrop.mobile.data.local.entity.FolderStatus import com.vaultdrop.mobile.data.local.entity.FolderStatus
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel 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.saf.safDisplayName
import com.vaultdrop.mobile.features.sync.SyncViewModel import com.vaultdrop.mobile.features.sync.SyncViewModel
import com.vaultdrop.mobile.features.saf.FileDeleter 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.MoveTargetBar
import com.vaultdrop.mobile.ui.navigation.NavTab import com.vaultdrop.mobile.ui.navigation.NavTab
import com.vaultdrop.mobile.ui.navigation.SelectionNavBar 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 kotlinx.coroutines.delay
import timber.log.Timber import timber.log.Timber
import java.util.Locale 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun FolderListScreen( fun FolderListScreen(
@@ -110,6 +123,8 @@ fun FolderListScreen(
viewModel: FolderListViewModel = hiltViewModel(), viewModel: FolderListViewModel = hiltViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val shareViewModel: ShareViewModel = hiltViewModel()
val shareState by shareViewModel.uiState.collectAsStateWithLifecycle()
val importState by syncViewModel.importState.collectAsStateWithLifecycle() val importState by syncViewModel.importState.collectAsStateWithLifecycle()
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
val defaultRootId by viewModel.defaultRootId.collectAsStateWithLifecycle() val defaultRootId by viewModel.defaultRootId.collectAsStateWithLifecycle()
@@ -122,8 +137,18 @@ fun FolderListScreen(
var pendingDeleteReview by remember { mutableStateOf<DeleteReview?>(null) } var pendingDeleteReview by remember { mutableStateOf<DeleteReview?>(null) }
var pendingFolderIds by remember { mutableStateOf<List<String>>(emptyList()) } var pendingFolderIds by remember { mutableStateOf<List<String>>(emptyList()) }
var pendingFileIds 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() } 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 val createError = uiState.createError
LaunchedEffect(createError) { LaunchedEffect(createError) {
if (createError != null) { if (createError != null) {
@@ -164,6 +189,7 @@ fun FolderListScreen(
// Racine par défaut : dossier VaultDrop choisi au premier lancement. // Racine par défaut : dossier VaultDrop choisi au premier lancement.
val defaultRootLabel = stringResource(R.string.default_root_folder_label) val defaultRootLabel = stringResource(R.string.default_root_folder_label)
val folderLabel = stringResource(R.string.folder)
var pendingDefaultPick by remember { mutableStateOf<Uri?>(null) } var pendingDefaultPick by remember { mutableStateOf<Uri?>(null) }
val pickDefaultRootLauncher = rememberLauncherForActivityResult( val pickDefaultRootLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree(), ActivityResultContracts.OpenDocumentTree(),
@@ -253,6 +279,33 @@ fun FolderListScreen(
IconButton(onClick = viewModel::refresh) { IconButton(onClick = viewModel::refresh) {
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.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) { selection.active -> if (uiState.view == HomeView.FOLDERS) {
val selectedFolders = uiState.browseSubFolders.filter {
it.resourceId in selection.ids
}
val onlyFolder = selectedFolders.singleOrNull()
FolderSelectionNavBar( FolderSelectionNavBar(
onDeleteModeSelected = { mode -> onShare = {
val selectedFolders = uiState.browseSubFolders.filter { onlyFolder?.let {
it.resourceId in selection.ids shareTarget = ShareTarget(it.resourceId, it.name)
} }
},
shareEnabled = shareEnabled,
onDeleteModeSelected = { mode ->
val selectedFiles = uiState.browseFiles.filter { val selectedFiles = uiState.browseFiles.filter {
it.resourceId in selection.ids 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. */ /** 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, val deleteError: String? = null,
/** Succès transitoire de suppression — affiché en Snackbar puis effacé. */ /** Succès transitoire de suppression — affiché en Snackbar puis effacé. */
val deleteSuccess: Boolean = false, 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.R
import com.vaultdrop.mobile.auth.TokenProvider import com.vaultdrop.mobile.auth.TokenProvider
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.referenceDate import com.vaultdrop.mobile.data.local.referenceDate
import com.vaultdrop.mobile.data.preferences.DefaultRootStore import com.vaultdrop.mobile.data.preferences.DefaultRootStore
import com.vaultdrop.mobile.data.remote.ApiException import com.vaultdrop.mobile.data.remote.ApiException
import com.vaultdrop.mobile.data.repository.FileRepository import com.vaultdrop.mobile.data.repository.FileRepository
import com.vaultdrop.mobile.data.repository.FolderRepository import com.vaultdrop.mobile.data.repository.FolderRepository
import com.vaultdrop.mobile.data.repository.SaveFolderInput 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.FileMover
import com.vaultdrop.mobile.features.saf.FileDeleter import com.vaultdrop.mobile.features.saf.FileDeleter
import com.vaultdrop.mobile.features.saf.FolderDeleter import com.vaultdrop.mobile.features.saf.FolderDeleter
@@ -47,6 +49,7 @@ class FolderListViewModel @Inject constructor(
private val fileMover: FileMover, private val fileMover: FileMover,
private val fileDeleter: FileDeleter, private val fileDeleter: FileDeleter,
private val folderDeleter: FolderDeleter, private val folderDeleter: FolderDeleter,
private val deviceIdentity: DeviceIdentity,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) : ViewModel() { ) : ViewModel() {
@@ -159,8 +162,34 @@ class FolderListViewModel @Inject constructor(
_uiState.update { it.copy(browseFiles = files) } _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). */ /** Descend dans l'explorateur Dossiers (aussi en mode déplacement). */
fun openBrowseFolder(folderId: String) { fun openBrowseFolder(folderId: String) {
_browseFolderId.value = folderId _browseFolderId.value = folderId
@@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.MergeType import androidx.compose.material.icons.filled.MergeType
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings 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.Dashboard
import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.Search 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 @Composable
fun FolderSelectionNavBar( fun FolderSelectionNavBar(
onShare: () -> Unit,
shareEnabled: Boolean,
onDeleteModeSelected: (FileDeleter.DeleteMode) -> Unit, onDeleteModeSelected: (FileDeleter.DeleteMode) -> Unit,
deleteEnabled: Boolean, deleteEnabled: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -204,6 +207,13 @@ fun FolderSelectionNavBar(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
SelectionAction(
label = stringResource(R.string.share_action),
icon = Icons.Filled.Share,
enabled = shareEnabled,
onClick = onShare,
modifier = Modifier.weight(1f),
)
DeleteDropdownButton( DeleteDropdownButton(
enabled = deleteEnabled, enabled = deleteEnabled,
onModeSelected = onDeleteModeSelected, onModeSelected = onDeleteModeSelected,