share files
This commit is contained in:
+78
-2
@@ -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) {
|
||||||
FolderSelectionNavBar(
|
|
||||||
onDeleteModeSelected = { mode ->
|
|
||||||
val selectedFolders = uiState.browseSubFolders.filter {
|
val selectedFolders = uiState.browseSubFolders.filter {
|
||||||
it.resourceId in selection.ids
|
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 {
|
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. */
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
)
|
)
|
||||||
+29
@@ -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,7 +162,33 @@ 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) {
|
||||||
|
|||||||
+11
-1
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user