share files
This commit is contained in:
+79
-3
@@ -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) {
|
||||
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. */
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
+29
@@ -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
|
||||
|
||||
+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.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,
|
||||
|
||||
Reference in New Issue
Block a user